Skip to content

Commit 3636e24

Browse files
committed
fix(studio): scope timeline ease focus lifecycle
1 parent 677fd31 commit 3636e24

26 files changed

Lines changed: 708 additions & 250 deletions

‎packages/studio/src/components/StudioRightPanel.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,8 +401,8 @@ export function StudioRightPanel({
401401
onUpdateArcSegment={handleUpdateArcSegment}
402402
onUnroll={handleUnroll}
403403
onUpdateKeyframeEase={handleUpdateKeyframeEase}
404-
onSetAllKeyframeEases={handleSetAllKeyframeEases}
405404
onUpdateSegmentEase={handleUpdateSegmentEase}
405+
onSetAllKeyframeEases={handleSetAllKeyframeEases}
406406
recordingState={recordingState}
407407
recordingDuration={recordingDuration}
408408
onToggleRecording={onToggleRecording}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { useState } from "react";
2+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3+
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
4+
import { usePlayerStore } from "../../player";
5+
import { isFocusedEaseRequestCurrent } from "../../player/store/keyframeSlice";
6+
import { AnimationCard } from "./AnimationCard";
7+
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
8+
import {
9+
type GsapAnimationEditCallbacks,
10+
withTrackedGsapAnimationCallbacks,
11+
} from "./gsapAnimationCallbacks";
12+
13+
interface GsapAnimationListProps extends GsapAnimationEditCallbacks {
14+
elementId: string;
15+
animations: GsapAnimation[];
16+
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
17+
variant: "classic" | "flat";
18+
}
19+
20+
/** Shared animation cards, telemetry, and timeline-ease focus ownership for both inspectors. */
21+
export function GsapAnimationList({
22+
elementId,
23+
animations,
24+
onAddAnimation,
25+
variant,
26+
...callbacks
27+
}: GsapAnimationListProps) {
28+
const track = useTrackDesignInput();
29+
const [addMenuOpen, setAddMenuOpen] = useState(false);
30+
const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
31+
const focusedEaseSegment = usePlayerStore((state) => state.focusedEaseSegment);
32+
const clearFocusedEaseSegment = usePlayerStore((state) => state.clearFocusedEaseSegment);
33+
const timelineProjectId = usePlayerStore((state) => state.timelineProjectId);
34+
const timelineSessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
35+
const selectedElementId = usePlayerStore((state) => state.selectedElementId);
36+
const focusedHere =
37+
focusedEaseSegment &&
38+
focusedEaseSegment.elementId === elementId &&
39+
isFocusedEaseRequestCurrent(focusedEaseSegment, {
40+
timelineProjectId,
41+
timelineSessionEpoch,
42+
selectedElementId,
43+
}) &&
44+
animations.some((animation) => animation.id === focusedEaseSegment.animationId)
45+
? focusedEaseSegment
46+
: null;
47+
48+
return (
49+
<div className="space-y-2">
50+
{animations.map((animation, index) => (
51+
<AnimationCard
52+
{...trackedCallbacks}
53+
key={animation.id}
54+
animation={animation}
55+
defaultExpanded={index === 0}
56+
flat={variant === "flat"}
57+
focusedSegment={focusedHere?.animationId === animation.id ? focusedHere : null}
58+
onFocusSegmentConsumed={() => {
59+
if (focusedHere?.animationId === animation.id) {
60+
clearFocusedEaseSegment(focusedHere.nonce);
61+
}
62+
}}
63+
/>
64+
))}
65+
<GsapAddAnimationControl
66+
open={addMenuOpen}
67+
setOpen={setAddMenuOpen}
68+
onAddAnimation={onAddAnimation}
69+
track={track}
70+
variant={variant}
71+
/>
72+
</div>
73+
);
74+
}

‎packages/studio/src/components/editor/GsapAnimationSection.test.tsx‎

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@ import { createRoot } from "react-dom/client";
55
import { afterEach, describe, expect, it, vi } from "vitest";
66
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
77
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
8-
import { usePlayerStore } from "../../player";
8+
import { usePlayerStore } from "../../player/store/playerStore";
99
import { GsapAnimationSection } from "./GsapAnimationSection";
1010

1111
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
1212

13+
const animationCardMock = vi.hoisted(() => ({
14+
consumers: [] as Array<{ tweenPercentage: number | null; consume: () => void }>,
15+
}));
16+
1317
vi.mock("./AnimationCard", () => ({
1418
AnimationCard: ({
1519
animation,
@@ -19,22 +23,27 @@ vi.mock("./AnimationCard", () => ({
1923
animation: GsapAnimation;
2024
focusedSegment: { tweenPercentage: number } | null;
2125
onFocusSegmentConsumed: () => void;
22-
}) => (
23-
<button
24-
type="button"
25-
data-testid={`animation-${animation.id}`}
26-
data-focused={focusedSegment ? String(focusedSegment.tweenPercentage) : ""}
27-
// Mirrors the real card: the consume callback only fires from the effect
28-
// that runs when this card actually received a focusedSegment.
29-
onClick={() => focusedSegment && onFocusSegmentConsumed()}
30-
/>
31-
),
26+
}) => {
27+
animationCardMock.consumers.push({
28+
tweenPercentage: focusedSegment?.tweenPercentage ?? null,
29+
consume: onFocusSegmentConsumed,
30+
});
31+
return (
32+
<button
33+
type="button"
34+
data-testid={`animation-${animation.id}`}
35+
data-focused={focusedSegment ? String(focusedSegment.tweenPercentage) : ""}
36+
onClick={onFocusSegmentConsumed}
37+
/>
38+
);
39+
},
3240
}));
3341

3442
vi.mock("./GsapAddAnimationControl", () => ({ GsapAddAnimationControl: () => null }));
3543

3644
afterEach(() => {
3745
document.body.innerHTML = "";
46+
animationCardMock.consumers = [];
3847
usePlayerStore.getState().reset();
3948
});
4049

@@ -78,6 +87,8 @@ function renderSection(elementId: string) {
7887

7988
describe("GsapAnimationSection", () => {
8089
it("consumes a shared animation id only for the focused element", () => {
90+
usePlayerStore.getState().beginTimelineSession("project-a");
91+
usePlayerStore.getState().setSelectedElementId("index.html#second");
8192
usePlayerStore.getState().setFocusedEaseSegment({
8293
elementId: "index.html#second",
8394
animationId: sharedAnimation.id,
@@ -99,4 +110,65 @@ describe("GsapAnimationSection", () => {
99110
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
100111
act(() => view.root.unmount());
101112
});
113+
114+
it("does not let an older consumer clear a newer request", () => {
115+
const store = usePlayerStore.getState();
116+
store.beginTimelineSession("project-a");
117+
store.setSelectedElementId("index.html#first");
118+
store.setFocusedEaseSegment({
119+
elementId: "index.html#first",
120+
animationId: sharedAnimation.id,
121+
tweenPercentage: 25,
122+
});
123+
const view = renderSection("index.html#first");
124+
const staleConsumer = animationCardMock.consumers.at(-1)?.consume;
125+
if (!staleConsumer) throw new Error("expected first focus consumer");
126+
127+
act(() => {
128+
store.setFocusedEaseSegment({
129+
elementId: "index.html#first",
130+
animationId: sharedAnimation.id,
131+
tweenPercentage: 75,
132+
});
133+
});
134+
const current = usePlayerStore.getState().focusedEaseSegment;
135+
if (!current) throw new Error("expected replacement request");
136+
137+
act(() => staleConsumer());
138+
expect(usePlayerStore.getState().focusedEaseSegment).toBe(current);
139+
140+
const currentConsumer = animationCardMock.consumers.at(-1)?.consume;
141+
if (!currentConsumer) throw new Error("expected replacement focus consumer");
142+
act(() => currentConsumer());
143+
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
144+
act(() => view.root.unmount());
145+
});
146+
147+
it("rejects a request from an earlier project session", () => {
148+
const store = usePlayerStore.getState();
149+
store.beginTimelineSession("project-a");
150+
store.setSelectedElementId("index.html#first");
151+
store.setFocusedEaseSegment({
152+
elementId: "index.html#first",
153+
animationId: sharedAnimation.id,
154+
tweenPercentage: 50,
155+
});
156+
const staleRequest = usePlayerStore.getState().focusedEaseSegment;
157+
if (!staleRequest) throw new Error("expected request");
158+
159+
const view = renderSection("index.html#first");
160+
161+
act(() => {
162+
store.beginTimelineSession("project-b");
163+
store.setSelectedElementId("index.html#first");
164+
usePlayerStore.setState({ focusedEaseSegment: staleRequest });
165+
});
166+
167+
const card = view.host.querySelector<HTMLButtonElement>(
168+
"[data-testid='animation-shared-animation']",
169+
);
170+
expect(card?.dataset.focused).toBe("");
171+
expect(usePlayerStore.getState().focusedEaseSegment).toBe(staleRequest);
172+
act(() => view.root.unmount());
173+
});
102174
});

‎packages/studio/src/components/editor/GsapAnimationSection.tsx‎

Lines changed: 11 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
1-
import { memo, useState } from "react";
1+
import { memo } from "react";
22
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
33
import { Film } from "../../icons/SystemIcons";
44
import { Section } from "./propertyPanelPrimitives";
5-
import { AnimationCard } from "./AnimationCard";
6-
import {
7-
type GsapAnimationEditCallbacks,
8-
withTrackedGsapAnimationCallbacks,
9-
clearFocusedEaseSegment,
10-
} from "./gsapAnimationCallbacks";
11-
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
12-
import { usePlayerStore } from "../../player";
13-
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
5+
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
6+
import { GsapAnimationList } from "./GsapAnimationList";
147

158
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
169
elementId: string;
@@ -21,18 +14,13 @@ interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
2114
}
2215

2316
export const GsapAnimationSection = memo(function GsapAnimationSection({
24-
animations,
2517
elementId,
18+
animations,
2619
multipleTimelines,
2720
unsupportedTimelinePattern,
2821
onAddAnimation,
2922
...callbacks
3023
}: GsapAnimationSectionProps) {
31-
const track = useTrackDesignInput();
32-
const [addMenuOpen, setAddMenuOpen] = useState(false);
33-
const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
34-
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
35-
3624
return (
3725
<Section title="Animation" icon={<Film size={15} />}>
3826
{multipleTimelines && (
@@ -49,31 +37,13 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
4937
</p>
5038
)}
5139
{multipleTimelines || unsupportedTimelinePattern ? null : (
52-
<div className="space-y-2">
53-
{animations.map((anim, index) => (
54-
<AnimationCard
55-
{...trackedCallbacks}
56-
key={anim.id}
57-
animation={anim}
58-
defaultExpanded={index === 0}
59-
focusedSegment={
60-
focusedEaseSegment?.elementId === elementId &&
61-
focusedEaseSegment.animationId === anim.id
62-
? focusedEaseSegment
63-
: null
64-
}
65-
onFocusSegmentConsumed={clearFocusedEaseSegment}
66-
/>
67-
))}
68-
69-
<GsapAddAnimationControl
70-
open={addMenuOpen}
71-
setOpen={setAddMenuOpen}
72-
onAddAnimation={onAddAnimation}
73-
track={track}
74-
variant="classic"
75-
/>
76-
</div>
40+
<GsapAnimationList
41+
{...callbacks}
42+
elementId={elementId}
43+
animations={animations}
44+
onAddAnimation={onAddAnimation}
45+
variant="classic"
46+
/>
7747
)}
7848
</Section>
7949
);

‎packages/studio/src/components/editor/PropertyPanel.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,8 +570,8 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
570570
onUpdateArcSegment={onUpdateArcSegment}
571571
onUnroll={onUnroll}
572572
onUpdateKeyframeEase={onUpdateKeyframeEase}
573-
onSetAllKeyframeEases={onSetAllKeyframeEases}
574573
onUpdateSegmentEase={onUpdateSegmentEase}
574+
onSetAllKeyframeEases={onSetAllKeyframeEases}
575575
/>
576576
)}
577577

‎packages/studio/src/components/editor/PropertyPanelFlat.tsx‎

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
2-
import { type ReactNode, useEffect, useRef, useState } from "react";
2+
import { useEffect, useRef, useState } from "react";
33
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
44
import { slugifyDesignInput } from "../../utils/designInputTracking";
55
import { isTextEditableSelection } from "./domEditing";
@@ -18,6 +18,7 @@ import { createGsapLivePreview } from "./gsapLivePreview";
1818
import { formatTextFieldPreview } from "./propertyPanelSections";
1919
import { useColorGradingController } from "./useColorGradingController";
2020
import { usePlayerStore } from "../../player";
21+
import { isFocusedEaseRequestCurrent } from "../../player/store/keyframeSlice";
2122
import {
2223
FlatColorGradingAccessory,
2324
FlatColorGradingSection,
@@ -31,24 +32,10 @@ import {
3132
deriveMediaOverlayPlacement,
3233
FlatOverlaysSection,
3334
} from "./propertyPanelFlatOverlaysSection";
34-
35-
type FlatGroupDescriptor = {
36-
id: string;
37-
title: string;
38-
summary?: string;
39-
accessory?: ReactNode;
40-
content: ReactNode;
41-
};
42-
43-
// Required callback shape for the gated-off Motion effect list.
44-
const EMPTY_GSAP_EFFECT_HANDLERS = {
45-
onAddAnimation: () => {},
46-
onUpdateProperty: () => {},
47-
onUpdateMeta: () => {},
48-
onDeleteAnimation: () => {},
49-
onAddProperty: () => {},
50-
onRemoveProperty: () => {},
51-
};
35+
import {
36+
EMPTY_GSAP_EFFECT_HANDLERS,
37+
type FlatGroupDescriptor,
38+
} from "./propertyPanelFlatDescriptors";
5239

5340
/** The flat inspector shell with one shared open-group state. */
5441
// fallow-ignore-next-line complexity
@@ -162,10 +149,13 @@ export function PropertyPanelFlat({
162149
// force the Motion group open so its AnimationCard (which only mounts while
163150
// the group is expanded) can consume the focus and reveal the ease editor.
164151
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
165-
// The element THIS panel renders, not the store's selectedElementId: that
166-
// flips synchronously while the panel still renders its predecessor, so a
167-
// stale panel would consume a request meant for its successor whenever the
168-
// two share a class-selector animation id.
152+
const timelineProjectId = usePlayerStore((s) => s.timelineProjectId);
153+
const timelineSessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
154+
// Identity of the element THIS panel actually renders (not the store's
155+
// selectedElementId, which flips synchronously on selection while the panel
156+
// still renders the previous element during async DOM-selection resolution):
157+
// a stale panel would otherwise consume a focus request meant for its
158+
// successor when both share a class-selector animation id.
169159
const renderedElementId = scopedElementKey(element);
170160
// Adjusted during render (not an effect) so the card mounts on the same
171161
// commit the request lands on. Keyed on request identity: a group the user
@@ -174,8 +164,16 @@ export function PropertyPanelFlat({
174164
if (focusedEaseSegment !== consumedFocus) {
175165
setConsumedFocus(focusedEaseSegment);
176166
const focusesThisPanel =
177-
focusedEaseSegment?.elementId === renderedElementId &&
178-
gsapAnimations.some((a) => a.id === focusedEaseSegment.animationId);
167+
focusedEaseSegment !== null &&
168+
// A request from a previous project/session/selection is stale: it must
169+
// not reopen Motion on whichever panel happens to be mounted now.
170+
isFocusedEaseRequestCurrent(focusedEaseSegment, {
171+
timelineProjectId,
172+
timelineSessionEpoch,
173+
selectedElementId,
174+
}) &&
175+
focusedEaseSegment.elementId === renderedElementId &&
176+
gsapAnimations.some((animation) => animation.id === focusedEaseSegment.animationId);
179177
if (focusesThisPanel) setOpenGroupId("motion");
180178
}
181179

0 commit comments

Comments
 (0)