diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index 1fac3972e..2ced7b6a8 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -6,6 +6,7 @@ import { AnimationCard } from "./AnimationCard"; import { type GsapAnimationEditCallbacks, withTrackedGsapAnimationCallbacks, + clearFocusedEaseSegment, } from "./gsapAnimationCallbacks"; import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; import { usePlayerStore } from "../../player"; @@ -29,7 +30,6 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ const [addMenuOpen, setAddMenuOpen] = useState(false); const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track); const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment); - const setFocusedEaseSegment = usePlayerStore((s) => s.setFocusedEaseSegment); return (
}> @@ -57,7 +57,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ focusedSegment={ focusedEaseSegment?.animationId === anim.id ? focusedEaseSegment : null } - onFocusSegmentConsumed={() => setFocusedEaseSegment(null)} + onFocusSegmentConsumed={clearFocusedEaseSegment} /> ))} diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index 86900c126..894713523 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -249,18 +249,22 @@ export function PropertyPanelFlat({ // force the Motion group open so its AnimationCard (which only mounts while // the group is expanded) can consume the focus and reveal the ease editor. const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment); - // Identity of the element THIS panel actually renders (not the store's - // selectedElementId, which flips synchronously on selection while the panel - // still renders the previous element during async DOM-selection resolution): - // a stale panel would otherwise consume a focus request meant for its - // successor when both share a class-selector animation id. + // The element THIS panel renders, not the store's selectedElementId: that + // flips synchronously while the panel still renders its predecessor, so a + // stale panel would consume a request meant for its successor whenever the + // two share a class-selector animation id. const renderedElementId = `${element.sourceFile}#${element.id}`; - useEffect(() => { - if (!focusedEaseSegment || focusedEaseSegment.elementId !== renderedElementId) return; - if (gsapAnimations.some((a) => a.id === focusedEaseSegment.animationId)) { - setOpenGroupId("motion"); - } - }, [focusedEaseSegment, gsapAnimations, renderedElementId]); + // Adjusted during render (not an effect) so the card mounts on the same + // commit the request lands on. Keyed on request identity: a group the user + // closes afterwards stays closed. + const [consumedFocus, setConsumedFocus] = useState(focusedEaseSegment); + if (focusedEaseSegment !== consumedFocus) { + setConsumedFocus(focusedEaseSegment); + const focusesThisPanel = + focusedEaseSegment?.elementId === renderedElementId && + gsapAnimations.some((a) => a.id === focusedEaseSegment.animationId); + if (focusesThisPanel) setOpenGroupId("motion"); + } const [justToggledIds, setJustToggledIds] = useState([]); const justToggledTimeoutRef = useRef | null>(null); diff --git a/packages/studio/src/components/editor/gsapAnimationCallbacks.ts b/packages/studio/src/components/editor/gsapAnimationCallbacks.ts index d35cf4479..bc5d78356 100644 --- a/packages/studio/src/components/editor/gsapAnimationCallbacks.ts +++ b/packages/studio/src/components/editor/gsapAnimationCallbacks.ts @@ -1,4 +1,5 @@ import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser"; +import { usePlayerStore } from "../../player"; /** * Edit callbacks shared by GsapAnimationSection and each AnimationCard it @@ -155,3 +156,12 @@ export function withTrackedGsapAnimationCallbacks( : undefined, }; } + +/** + * Stable consumer for the store's one-shot ease-focus request. Module-level on + * purpose: an inline arrow in the section components is a dep of AnimationCard's + * focus effect, so a fresh identity each render re-runs that effect every render. + */ +export function clearFocusedEaseSegment(): void { + usePlayerStore.getState().setFocusedEaseSegment(null); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx index c8edf195b..4823a995a 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx @@ -9,6 +9,7 @@ import { AnimationCard } from "./AnimationCard"; import { type GsapAnimationEditCallbacks, withTrackedGsapAnimationCallbacks, + clearFocusedEaseSegment, } from "./gsapAnimationCallbacks"; import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; import { usePlayerStore } from "../../player"; @@ -138,7 +139,6 @@ export function FlatMotionSection({ const [addMenuOpen, setAddMenuOpen] = useState(false); const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track); const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment); - const setFocusedEaseSegment = usePlayerStore((s) => s.setFocusedEaseSegment); // Only consume a focus request aimed at the element THIS panel renders (not // the store's selectedElementId, which flips synchronously during async // selection resolution), so a shared class-selector animation id can't open @@ -182,7 +182,7 @@ export function FlatMotionSection({ defaultExpanded={index === 0} flat focusedSegment={focusedHere?.animationId === anim.id ? focusedHere : null} - onFocusSegmentConsumed={() => setFocusedEaseSegment(null)} + onFocusSegmentConsumed={clearFocusedEaseSegment} /> ))} { view.unmount(); }); + // The diamond context menu opens on whatever diamond was clicked, which need + // not belong to the selected element, and it passes no explicit target — so + // the resolve falls back to the cache. Reading the SELECTED element's cache + // there resolves against the wrong element's keyframes. + it("resolves a cache fallback against the clicked element, not the selected one", () => { + 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", [otherKeyframedAnimation]]]), + keyframeCache: new Map([ + // Decoy at the same clip-% under the selected element's key. + [ + "box", + { + format: "percentage", + keyframes: [{ percentage: 100, tweenPercentage: 100, properties: { x: 420 } }], + }, + ], + [ + "scenes/main.html#circle", + { + format: "percentage", + keyframes: [ + { + percentage: 100, + tweenPercentage: 100, + propertyGroup: "position", + animationId: otherKeyframedAnimation.id, + properties: { x: 420 }, + }, + ], + }, + ], + ]), + }); + const view = renderCallbacks(); + + act(() => { + view.callbacks.onMoveKeyframeToPlayhead?.("scenes/main.html#circle", 100); + }); + + expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).toHaveBeenCalledWith( + otherKeyframedAnimation.id, + 100, + ); + view.unmount(); + }); + it("keeps selected-element flat boundary deletion on the animation delete path", () => { const view = renderCallbacks(); diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts index c800528ae..e72d4bc7b 100644 --- a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts @@ -143,12 +143,18 @@ export function useTimelineEditCallbacks({ tweenPercentage?: number, animationId?: string, animations: GsapAnimation[] = selectedGsapAnimations, + elementKey?: string, ): { animId: string; tweenPct: number } | null => { const explicitTarget = propertyGroup !== undefined || tweenPercentage !== undefined || animationId !== undefined ? [{ percentage: pct, propertyGroup, tweenPercentage, animationId }] : undefined; - const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? ""); + // The clicked element's own cache when the caller knows it: the diamond + // context menu can open on an element that is not the selected one, and + // reading the selection's cache there resolves against the wrong element. + const cached = usePlayerStore + .getState() + .keyframeCache.get(elementKey ?? domEditSelection?.id ?? ""); return resolveTimelineKeyframeTarget( pct, explicitTarget ?? cached?.keyframes ?? [], @@ -198,7 +204,7 @@ export function useTimelineEditCallbacks({ }, onDeleteKeyframe: (elId, pct, group, tweenPct, animationId) => { const animations = resolveElementAnimations(elId); - const target = resolveKeyframeTarget(pct, group, tweenPct, animationId, animations); + const target = resolveKeyframeTarget(pct, group, tweenPct, animationId, animations, elId); if (!target) return; const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId); if (!element) { @@ -213,8 +219,15 @@ export function useTimelineEditCallbacks({ }); }, // Retime the keyframe to the playhead, preserving its value + ease. - onMoveKeyframeToPlayhead: (_elId, pct, group, tweenPct, animationId) => { - const target = resolveKeyframeTarget(pct, group, tweenPct, animationId); + onMoveKeyframeToPlayhead: (elId, pct, group, tweenPct, animationId) => { + const target = resolveKeyframeTarget( + pct, + group, + tweenPct, + animationId, + resolveElementAnimations(elId), + elId, + ); if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct); }, // Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives