diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx
index baf7d1fb3b..66c391be7b 100644
--- a/packages/studio/src/components/StudioRightPanel.tsx
+++ b/packages/studio/src/components/StudioRightPanel.tsx
@@ -399,8 +399,8 @@ export function StudioRightPanel({
onUpdateArcSegment={handleUpdateArcSegment}
onUnroll={handleUnroll}
onUpdateKeyframeEase={handleUpdateKeyframeEase}
- onSetAllKeyframeEases={handleSetAllKeyframeEases}
onUpdateSegmentEase={handleUpdateSegmentEase}
+ onSetAllKeyframeEases={handleSetAllKeyframeEases}
recordingState={recordingState}
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
diff --git a/packages/studio/src/components/editor/GsapAnimationSection.test.tsx b/packages/studio/src/components/editor/GsapAnimationSection.test.tsx
index 8517860d1f..240ae98625 100644
--- a/packages/studio/src/components/editor/GsapAnimationSection.test.tsx
+++ b/packages/studio/src/components/editor/GsapAnimationSection.test.tsx
@@ -5,11 +5,15 @@ import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
-import { usePlayerStore } from "../../player";
+import { usePlayerStore } from "../../player/store/playerStore";
import { GsapAnimationSection } from "./GsapAnimationSection";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+const animationCardMock = vi.hoisted(() => ({
+ consumers: [] as Array<{ tweenPercentage: number | null; consume: () => void }>,
+}));
+
vi.mock("./AnimationCard", () => ({
AnimationCard: ({
animation,
@@ -19,20 +23,27 @@ vi.mock("./AnimationCard", () => ({
animation: GsapAnimation;
focusedSegment: { tweenPercentage: number } | null;
onFocusSegmentConsumed: () => void;
- }) => (
-
- ),
+ }) => {
+ animationCardMock.consumers.push({
+ tweenPercentage: focusedSegment?.tweenPercentage ?? null,
+ consume: onFocusSegmentConsumed,
+ });
+ return (
+
+ );
+ },
}));
vi.mock("./GsapAddAnimationControl", () => ({ GsapAddAnimationControl: () => null }));
afterEach(() => {
document.body.innerHTML = "";
+ animationCardMock.consumers = [];
usePlayerStore.getState().reset();
});
@@ -76,6 +87,8 @@ function renderSection(elementId: string) {
describe("GsapAnimationSection", () => {
it("consumes a shared animation id only for the focused element", () => {
+ usePlayerStore.getState().beginTimelineSession("project-a");
+ usePlayerStore.getState().setSelectedElementId("index.html#second");
usePlayerStore.getState().setFocusedEaseSegment({
elementId: "index.html#second",
animationId: sharedAnimation.id,
@@ -97,4 +110,65 @@ describe("GsapAnimationSection", () => {
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
act(() => view.root.unmount());
});
+
+ it("does not let an older consumer clear a newer request", () => {
+ const store = usePlayerStore.getState();
+ store.beginTimelineSession("project-a");
+ store.setSelectedElementId("index.html#first");
+ store.setFocusedEaseSegment({
+ elementId: "index.html#first",
+ animationId: sharedAnimation.id,
+ tweenPercentage: 25,
+ });
+ const view = renderSection("index.html#first");
+ const staleConsumer = animationCardMock.consumers.at(-1)?.consume;
+ if (!staleConsumer) throw new Error("expected first focus consumer");
+
+ act(() => {
+ store.setFocusedEaseSegment({
+ elementId: "index.html#first",
+ animationId: sharedAnimation.id,
+ tweenPercentage: 75,
+ });
+ });
+ const current = usePlayerStore.getState().focusedEaseSegment;
+ if (!current) throw new Error("expected replacement request");
+
+ act(() => staleConsumer());
+ expect(usePlayerStore.getState().focusedEaseSegment).toBe(current);
+
+ const currentConsumer = animationCardMock.consumers.at(-1)?.consume;
+ if (!currentConsumer) throw new Error("expected replacement focus consumer");
+ act(() => currentConsumer());
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
+ act(() => view.root.unmount());
+ });
+
+ it("rejects a request from an earlier project session", () => {
+ const store = usePlayerStore.getState();
+ store.beginTimelineSession("project-a");
+ store.setSelectedElementId("index.html#first");
+ store.setFocusedEaseSegment({
+ elementId: "index.html#first",
+ animationId: sharedAnimation.id,
+ tweenPercentage: 50,
+ });
+ const staleRequest = usePlayerStore.getState().focusedEaseSegment;
+ if (!staleRequest) throw new Error("expected request");
+
+ const view = renderSection("index.html#first");
+
+ act(() => {
+ store.beginTimelineSession("project-b");
+ store.setSelectedElementId("index.html#first");
+ usePlayerStore.setState({ focusedEaseSegment: staleRequest });
+ });
+
+ const card = view.host.querySelector(
+ "[data-testid='animation-shared-animation']",
+ );
+ expect(card?.dataset.focused).toBe("");
+ expect(usePlayerStore.getState().focusedEaseSegment).toBe(staleRequest);
+ act(() => view.root.unmount());
+ });
});
diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx
index d5aa391ed4..391742e14e 100644
--- a/packages/studio/src/components/editor/GsapAnimationSection.tsx
+++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx
@@ -9,6 +9,7 @@ import {
} from "./gsapAnimationCallbacks";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import { usePlayerStore } from "../../player";
+import { isFocusedEaseRequestCurrent } from "../../player/store/keyframeSlice";
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
@@ -20,8 +21,8 @@ interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
}
export const GsapAnimationSection = memo(function GsapAnimationSection({
- animations,
elementId,
+ animations,
multipleTimelines,
unsupportedTimelinePattern,
onAddAnimation,
@@ -31,7 +32,24 @@ 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);
+ const clearFocusedEaseSegment = usePlayerStore((s) => s.clearFocusedEaseSegment);
+ const timelineProjectId = usePlayerStore((s) => s.timelineProjectId);
+ const timelineSessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
+ const selectedElementId = usePlayerStore((s) => s.selectedElementId);
+ const focusedRequestIsCurrent =
+ focusedEaseSegment !== null &&
+ isFocusedEaseRequestCurrent(focusedEaseSegment, {
+ timelineProjectId,
+ timelineSessionEpoch,
+ selectedElementId,
+ });
+ const focusedAnimationExists =
+ focusedEaseSegment !== null &&
+ animations.some((animation) => animation.id === focusedEaseSegment.animationId);
+ const focusedHere =
+ focusedRequestIsCurrent && focusedEaseSegment.elementId === elementId && focusedAnimationExists
+ ? focusedEaseSegment
+ : null;
return (
}>
@@ -56,16 +74,10 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
key={anim.id}
animation={anim}
defaultExpanded={index === 0}
- focusedSegment={
- focusedEaseSegment?.elementId === elementId &&
- focusedEaseSegment.animationId === anim.id
- ? focusedEaseSegment
- : null
- }
+ focusedSegment={focusedHere?.animationId === anim.id ? focusedHere : null}
onFocusSegmentConsumed={() => {
- const focused = usePlayerStore.getState().focusedEaseSegment;
- if (focused?.elementId === elementId && focused.animationId === anim.id) {
- setFocusedEaseSegment(null);
+ if (focusedHere?.animationId === anim.id) {
+ clearFocusedEaseSegment(focusedHere.nonce);
}
}}
/>
diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx
index a7c08be02f..22fc9f3724 100644
--- a/packages/studio/src/components/editor/PropertyPanel.tsx
+++ b/packages/studio/src/components/editor/PropertyPanel.tsx
@@ -568,8 +568,8 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onUpdateArcSegment={onUpdateArcSegment}
onUnroll={onUnroll}
onUpdateKeyframeEase={onUpdateKeyframeEase}
- onSetAllKeyframeEases={onSetAllKeyframeEases}
onUpdateSegmentEase={onUpdateSegmentEase}
+ onSetAllKeyframeEases={onSetAllKeyframeEases}
/>
)}
diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx
index 327fedf62e..d218c1c057 100644
--- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx
+++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx
@@ -1,5 +1,4 @@
-import { type ReactNode, useEffect, useRef, useState } from "react";
-import { resolveEditingSections } from "@hyperframes/core/editing";
+import { useEffect, useRef, useState } from "react";
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
import { slugifyDesignInput } from "../../utils/designInputTracking";
import type { DomEditSelection } from "./domEditing";
@@ -20,34 +19,16 @@ import { formatTextFieldPreview } from "./propertyPanelSections";
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
import { useColorGradingController } from "./useColorGradingController";
import { usePlayerStore } from "../../player";
+import { isFocusedEaseRequestCurrent } from "../../player/store/keyframeSlice";
import {
FlatColorGradingAccessory,
FlatColorGradingSection,
} from "./propertyPanelFlatColorGradingSection";
-
-type EditingSections = ReturnType;
-
-type FlatGroupDescriptor = {
- id: string;
- title: string;
- summary?: string;
- accessory?: ReactNode;
- content: ReactNode;
-};
-
-// Type-only fallback for the Motion effect-card callbacks. Used solely to
-// satisfy FlatMotionSection's required-callback shape when the effect list is
-// gated off (showEffects === false, so none of these are ever invoked). Keeps
-// the gated-off path free of `!` non-null assertions — the real, narrowed
-// handlers flow through only when the double-gate below passes.
-const EMPTY_GSAP_EFFECT_HANDLERS = {
- onAddAnimation: () => {},
- onUpdateProperty: () => {},
- onUpdateMeta: () => {},
- onDeleteAnimation: () => {},
- onAddProperty: () => {},
- onRemoveProperty: () => {},
-};
+import {
+ EMPTY_GSAP_EFFECT_HANDLERS,
+ type EditingSections,
+ type FlatGroupDescriptor,
+} from "./propertyPanelFlatDescriptors";
/**
* The flat "Ledger" inspector shell (design_handoff_studio_inspector).
@@ -184,8 +165,8 @@ export function PropertyPanelFlat({
| "onUpdateArcSegment"
| "onUnroll"
| "onUpdateKeyframeEase"
- | "onSetAllKeyframeEases"
| "onUpdateSegmentEase"
+ | "onSetAllKeyframeEases"
| "recordingState"
| "recordingDuration"
| "onToggleRecording"
@@ -263,6 +244,8 @@ 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);
+ const timelineProjectId = usePlayerStore((s) => s.timelineProjectId);
+ const timelineSessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
// 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):
@@ -270,11 +253,27 @@ export function PropertyPanelFlat({
// successor when both 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]);
+ if (!focusedEaseSegment) return;
+ if (
+ !isFocusedEaseRequestCurrent(focusedEaseSegment, {
+ timelineProjectId,
+ timelineSessionEpoch,
+ selectedElementId,
+ })
+ )
+ return;
+ if (focusedEaseSegment.elementId !== renderedElementId) return;
+ if (!gsapAnimations.some((animation) => animation.id === focusedEaseSegment.animationId))
+ return;
+ setOpenGroupId("motion");
+ }, [
+ focusedEaseSegment,
+ gsapAnimations,
+ renderedElementId,
+ selectedElementId,
+ timelineProjectId,
+ timelineSessionEpoch,
+ ]);
const [justToggledIds, setJustToggledIds] = useState([]);
const justToggledTimeoutRef = useRef | null>(null);
diff --git a/packages/studio/src/components/editor/gsapAnimationCallbacks.test.ts b/packages/studio/src/components/editor/gsapAnimationCallbacks.test.ts
index 1707b92c00..c76f0e7be3 100644
--- a/packages/studio/src/components/editor/gsapAnimationCallbacks.test.ts
+++ b/packages/studio/src/components/editor/gsapAnimationCallbacks.test.ts
@@ -27,8 +27,8 @@ describe("withTrackedGsapAnimationCallbacks", () => {
const onUpdateSegmentEase = vi.fn();
callbacks.onLivePreview = onLivePreview;
callbacks.onLivePreviewEnd = onLivePreviewEnd;
-
callbacks.onUpdateSegmentEase = onUpdateSegmentEase;
+
const tracked = withTrackedGsapAnimationCallbacks(callbacks, vi.fn());
expect(tracked.onUpdateFromProperty).toBeUndefined();
diff --git a/packages/studio/src/components/editor/propertyPanelFlatDescriptors.ts b/packages/studio/src/components/editor/propertyPanelFlatDescriptors.ts
new file mode 100644
index 0000000000..df1742c6da
--- /dev/null
+++ b/packages/studio/src/components/editor/propertyPanelFlatDescriptors.ts
@@ -0,0 +1,23 @@
+import type { ReactNode } from "react";
+import type { resolveEditingSections } from "@hyperframes/core/editing";
+
+export type EditingSections = ReturnType;
+
+export type FlatGroupDescriptor = {
+ id: string;
+ title: string;
+ summary?: string;
+ accessory?: ReactNode;
+ content: ReactNode;
+};
+
+// FlatMotionSection never calls these while effect cards are hidden; they only
+// provide its required callback shape on the gated-off path.
+export const EMPTY_GSAP_EFFECT_HANDLERS = {
+ onAddAnimation: () => {},
+ onUpdateProperty: () => {},
+ onUpdateMeta: () => {},
+ onDeleteAnimation: () => {},
+ onAddProperty: () => {},
+ onRemoveProperty: () => {},
+};
diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx
index fa14727290..6864307a0a 100644
--- a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx
@@ -11,8 +11,8 @@ import { usePlayerStore } from "../../player";
afterEach(() => {
document.body.innerHTML = "";
+ usePlayerStore.getState().reset();
});
-usePlayerStore.getState().reset();
function baseElement(overrides: Partial = {}): DomEditSelection {
return {
@@ -271,13 +271,14 @@ describe("FlatMotionSection", () => {
expect(buttons().some((b) => b.textContent === "+ Add effect")).toBe(true);
act(() => root.unmount());
});
-});
-it("forwards focused bulk segment easing through the flat animation card", () => {
- const onUpdateKeyframeEase = vi.fn();
- const onUpdateSegmentEase = vi.fn();
- usePlayerStore.setState({
- focusedEaseSegment: {
+ it("forwards focused bulk segment easing through the flat animation card", () => {
+ const onUpdateKeyframeEase = vi.fn();
+ const onUpdateSegmentEase = vi.fn();
+ const store = usePlayerStore.getState();
+ store.beginTimelineSession("project-a");
+ store.setSelectedElementId("index.html#hero");
+ store.setFocusedEaseSegment({
elementId: "index.html#hero",
animationId: "a1",
tweenPercentage: 50,
@@ -285,55 +286,55 @@ it("forwards focused bulk segment easing through the flat animation card", () =>
{ animationId: "a1", tweenPercentage: 50 },
{ animationId: "a2", tweenPercentage: 75 },
],
- },
- });
- const { host, root } = renderInto(
- ,
- );
+ });
+ const { host, root } = renderInto(
+ ,
+ );
- const dropdown = host.querySelector("[data-ease-type-dropdown]");
- expect(dropdown).not.toBeNull();
- act(() => dropdown?.click());
- const preset = host.querySelector('[data-ease-preset-id="quad-out"]');
- expect(preset).not.toBeNull();
- act(() => preset?.click());
+ const dropdown = host.querySelector("[data-ease-type-dropdown]");
+ expect(dropdown).not.toBeNull();
+ act(() => dropdown?.click());
+ const preset = host.querySelector('[data-ease-preset-id="quad-out"]');
+ expect(preset).not.toBeNull();
+ act(() => preset?.click());
- expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(
- [
- { animationId: "a1", tweenPercentage: 50 },
- { animationId: "a2", tweenPercentage: 75 },
- ],
- "power2.out",
- );
- expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
- act(() => root.unmount());
+ expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(
+ [
+ { animationId: "a1", tweenPercentage: 50 },
+ { animationId: "a2", tweenPercentage: 75 },
+ ],
+ "power2.out",
+ );
+ expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
+ act(() => root.unmount());
+ });
});
diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx
index c8edf195b5..b53cfbf7f0 100644
--- a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx
@@ -12,6 +12,7 @@ import {
} from "./gsapAnimationCallbacks";
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
import { usePlayerStore } from "../../player";
+import { isFocusedEaseRequestCurrent } from "../../player/store/keyframeSlice";
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
export function FlatTimingRow({
@@ -138,14 +139,24 @@ 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);
+ const clearFocusedEaseSegment = usePlayerStore((s) => s.clearFocusedEaseSegment);
+ const timelineProjectId = usePlayerStore((s) => s.timelineProjectId);
+ const timelineSessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
+ const selectedElementId = usePlayerStore((s) => s.selectedElementId);
// 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
// the wrong element's editor.
const renderedElementId = `${element.sourceFile}#${element.id}`;
const focusedHere =
- focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId
+ focusedEaseSegment &&
+ focusedEaseSegment.elementId === renderedElementId &&
+ isFocusedEaseRequestCurrent(focusedEaseSegment, {
+ timelineProjectId,
+ timelineSessionEpoch,
+ selectedElementId,
+ }) &&
+ animations.some((animation) => animation.id === focusedEaseSegment.animationId)
? focusedEaseSegment
: null;
@@ -182,7 +193,11 @@ export function FlatMotionSection({
defaultExpanded={index === 0}
flat
focusedSegment={focusedHere?.animationId === anim.id ? focusedHere : null}
- onFocusSegmentConsumed={() => setFocusedEaseSegment(null)}
+ onFocusSegmentConsumed={() => {
+ if (focusedHere?.animationId === anim.id) {
+ clearFocusedEaseSegment(focusedHere.nonce);
+ }
+ }}
/>
))}
void;
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
- onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
onUpdateSegmentEase?: NonNullable;
+ onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
onConvertToKeyframes?: (animationId: string, duration?: number) => void;
onCommitAnimatedProperty?: (
selection: DomEditSelection,
diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx
index 53a9039e5f..f89e27e915 100644
--- a/packages/studio/src/contexts/DomEditContext.tsx
+++ b/packages/studio/src/contexts/DomEditContext.tsx
@@ -200,8 +200,8 @@ export function DomEditProvider({
commitMutation,
applyMarqueeSelection,
handleUpdateKeyframeEase,
- handleSetAllKeyframeEases,
handleUpdateSegmentEase,
+ handleSetAllKeyframeEases,
},
children,
}: {
diff --git a/packages/studio/src/hooks/useDomEditSession.test.tsx b/packages/studio/src/hooks/useDomEditSession.test.tsx
index c2b58e718e..ab84bf915c 100644
--- a/packages/studio/src/hooks/useDomEditSession.test.tsx
+++ b/packages/studio/src/hooks/useDomEditSession.test.tsx
@@ -57,9 +57,9 @@ const recordResolverParity = vi.fn<(...args: unknown[]) => Promise>(async
const capturedOnReorderShadow: { fn: ((targets: string[]) => void) | undefined } = {
fn: undefined,
};
-
const domEditSelectionRef: { current: DomEditSelection | null } = { current: null };
const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
+
vi.mock("../utils/sdkResolverShadow", () => ({
runResolverShadow: vi.fn(),
recordResolverParity: (...args: unknown[]) => recordResolverParity(...args),
diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts
index 40566d8d95..57e8297d86 100644
--- a/packages/studio/src/hooks/useDomEditSession.ts
+++ b/packages/studio/src/hooks/useDomEditSession.ts
@@ -584,11 +584,11 @@ export function useDomEditSession({
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
handleUpdateKeyframeEase,
+ handleUpdateSegmentEase,
handleSetAllKeyframeEases,
commitAnimatedProperty,
commitAnimatedProperties,
handleSetArcPath,
- handleUpdateSegmentEase,
handleUpdateArcSegment,
handleUnroll,
invalidateGsapCache: bumpGsapCache,
diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts
index f0dd3d890e..814295d025 100644
--- a/packages/studio/src/player/components/Timeline.test.ts
+++ b/packages/studio/src/player/components/Timeline.test.ts
@@ -301,7 +301,6 @@ describe("Timeline provider boundary", () => {
act(() => root.unmount());
});
- // fallow-ignore-next-line code-duplication
it("renders the gutter without legacy icons or hue dots", () => {
const { host, root } = renderBasicTimeline();
@@ -398,8 +397,8 @@ describe("Timeline provider boundary", () => {
);
});
- const viewport = host.querySelector('[aria-label="Timeline"]')?.firstElementChild;
- expect(viewport).toBeInstanceOf(HTMLElement);
+ const viewport = host.querySelector("[data-timeline-scroll-viewport]");
+ expect(viewport).not.toBeNull();
act(() => {
viewport?.dispatchEvent(
new MouseEvent("pointerdown", {
diff --git a/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx b/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx
index f2cdca96a5..dc6f8919e3 100644
--- a/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx
+++ b/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx
@@ -91,7 +91,7 @@ describe("useTimelineKeyframeHandlers", () => {
const root = mountReactHarness();
act(() => onSelectSegment?.(ELEMENT.id, COLLIDING_TARGET));
- expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
+ expect(usePlayerStore.getState().focusedEaseSegment).toMatchObject({
animationId: "position-tween",
collidingAnimationTargets: [
{ animationId: "position-tween", tweenPercentage: 100 },
@@ -130,7 +130,7 @@ describe("useTimelineKeyframeHandlers", () => {
// Selecting a segment must NOT move the playhead.
act(() => onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
expect(onSeek).not.toHaveBeenCalled();
- expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
+ expect(usePlayerStore.getState().focusedEaseSegment).toMatchObject({
animationId: "position-tween",
tweenPercentage: 100,
elementId: ELEMENT.id,
diff --git a/packages/studio/src/player/store/keyframeSlice.ts b/packages/studio/src/player/store/keyframeSlice.ts
index 858b8a640b..d22f8d2b90 100644
--- a/packages/studio/src/player/store/keyframeSlice.ts
+++ b/packages/studio/src/player/store/keyframeSlice.ts
@@ -22,6 +22,34 @@ export interface KeyframeCacheEntry {
easeEach?: string;
}
+export interface FocusedEaseSegment {
+ animationId: string;
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
+ tweenPercentage: number;
+ elementId: string;
+ projectId: string | null;
+ sessionEpoch: number;
+ nonce: number;
+}
+
+type FocusedEaseSegmentTarget = Omit;
+
+interface TimelineSessionIdentity {
+ timelineProjectId: string | null;
+ timelineSessionEpoch: number;
+}
+
+export function isFocusedEaseRequestCurrent(
+ request: FocusedEaseSegment,
+ state: TimelineSessionIdentity & { selectedElementId: string | null },
+): boolean {
+ return (
+ request.projectId === state.timelineProjectId &&
+ request.sessionEpoch === state.timelineSessionEpoch &&
+ request.elementId === state.selectedElementId
+ );
+}
+
export interface KeyframeSlice {
/** Selected collapsed (`element:pct`) or expanded (`element:group:animation:clipPct`) diamonds. */
selectedKeyframes: Set;
@@ -35,22 +63,11 @@ export interface KeyframeSlice {
/** Union-expand clips (keyframed clips are expanded by default on load). */
expandClips: (ids: readonly string[]) => void;
- /** 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;
- collidingAnimationTargets?: AnimationKeyframeTarget[];
- tweenPercentage: number;
- elementId: string;
- } | null;
- setFocusedEaseSegment: (
- target: {
- animationId: string;
- collidingAnimationTargets?: AnimationKeyframeTarget[];
- tweenPercentage: number;
- elementId: string;
- } | null,
- ) => void;
+ /** Project/session/element-scoped request. Its nonce makes stale consumers harmless. */
+ focusedEaseSegment: FocusedEaseSegment | null;
+ focusedEaseRequestNonce: number;
+ setFocusedEaseSegment: (target: FocusedEaseSegmentTarget) => void;
+ clearFocusedEaseSegment: (nonce: number) => void;
/** Keyframe data per element id, populated from parsed GSAP animations. */
keyframeCache: Map;
@@ -60,7 +77,10 @@ export interface KeyframeSlice {
setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void;
}
-export function createKeyframeSlice(set: StoreApi["setState"]): KeyframeSlice {
+export function createKeyframeSlice(
+ set: StoreApi["setState"],
+ getTimelineSessionIdentity: () => TimelineSessionIdentity,
+): KeyframeSlice {
return {
selectedKeyframes: new Set(),
toggleSelectedKeyframe: (key) =>
@@ -97,7 +117,25 @@ export function createKeyframeSlice(set: StoreApi["setState"]): K
}),
focusedEaseSegment: null,
- setFocusedEaseSegment: (target) => set({ focusedEaseSegment: target }),
+ focusedEaseRequestNonce: 0,
+ setFocusedEaseSegment: (target) =>
+ set((state) => {
+ const nonce = state.focusedEaseRequestNonce + 1;
+ const { timelineProjectId, timelineSessionEpoch } = getTimelineSessionIdentity();
+ return {
+ focusedEaseRequestNonce: nonce,
+ focusedEaseSegment: {
+ ...target,
+ projectId: timelineProjectId,
+ sessionEpoch: timelineSessionEpoch,
+ nonce,
+ },
+ };
+ }),
+ clearFocusedEaseSegment: (nonce) =>
+ set((state) =>
+ state.focusedEaseSegment?.nonce === nonce ? { focusedEaseSegment: null } : state,
+ ),
keyframeCache: new Map(),
setKeyframeCache: (elementId, data) =>
diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts
index b9b1157b22..8adfd77eeb 100644
--- a/packages/studio/src/player/store/playerStore.test.ts
+++ b/packages/studio/src/player/store/playerStore.test.ts
@@ -53,6 +53,82 @@ describe("usePlayerStore", () => {
});
});
+ describe("focused ease requests", () => {
+ it("stamps the current project session and only lets its nonce clear it", () => {
+ const store = usePlayerStore.getState();
+ store.beginTimelineSession("project-a");
+ store.setSelectedElementId("index.html#hero");
+ store.setFocusedEaseSegment({
+ elementId: "index.html#hero",
+ animationId: "animation-a",
+ tweenPercentage: 50,
+ });
+ const first = usePlayerStore.getState().focusedEaseSegment;
+ if (!first) throw new Error("expected focused ease request");
+ expect(first.projectId).toBe("project-a");
+ expect(first.sessionEpoch).toBeGreaterThan(0);
+ expect(first.nonce).toBeGreaterThan(0);
+
+ store.setFocusedEaseSegment({
+ elementId: "index.html#hero",
+ animationId: "animation-a",
+ tweenPercentage: 75,
+ });
+ const second = usePlayerStore.getState().focusedEaseSegment;
+ if (!second) throw new Error("expected replacement request");
+ expect(second.nonce).toBe(first.nonce + 1);
+
+ store.clearFocusedEaseSegment(first.nonce);
+ expect(usePlayerStore.getState().focusedEaseSegment).toBe(second);
+ store.clearFocusedEaseSegment(second.nonce);
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
+ });
+
+ it("clears a pending request when the project session changes", () => {
+ const store = usePlayerStore.getState();
+ store.beginTimelineSession("project-a");
+ store.setFocusedEaseSegment({
+ elementId: "index.html#hero",
+ animationId: "animation-a",
+ tweenPercentage: 50,
+ });
+
+ store.beginTimelineSession("project-b");
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
+ });
+
+ it("does not revive an old request after selecting away and back", () => {
+ const store = usePlayerStore.getState();
+ store.setSelectedElementId("index.html#a");
+ store.setFocusedEaseSegment({
+ elementId: "index.html#a",
+ animationId: "animation-a",
+ tweenPercentage: 50,
+ });
+
+ store.setSelectedElementId("index.html#b");
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
+ store.setSelectedElementId("index.html#a");
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
+ });
+
+ it("invalidates on a genuine selection-anchor change but not a same-anchor echo", () => {
+ const store = usePlayerStore.getState();
+ store.setSelection(new Set(["index.html#a", "index.html#b"]), "index.html#a");
+ store.setFocusedEaseSegment({
+ elementId: "index.html#a",
+ animationId: "animation-a",
+ tweenPercentage: 50,
+ });
+ const request = usePlayerStore.getState().focusedEaseSegment;
+
+ store.setSelectionAnchor("index.html#a");
+ expect(usePlayerStore.getState().focusedEaseSegment).toBe(request);
+ store.setSelectionAnchor("index.html#b");
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
+ });
+ });
+
describe("setIsPlaying", () => {
it("sets isPlaying to true", () => {
usePlayerStore.getState().setIsPlaying(true);
diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts
index bd17f13389..ff5400c6c8 100644
--- a/packages/studio/src/player/store/playerStore.ts
+++ b/packages/studio/src/player/store/playerStore.ts
@@ -305,6 +305,7 @@ function createTimelineResetState() {
selectedKeyframes: new Set(),
expandedClipIds: new Set(),
selectedElementIds: new Set(),
+ focusedEaseSegment: null,
clipRevealRequest: null,
keyframeCache: new Map(),
gsapAnimations: new Map(),
@@ -343,7 +344,7 @@ export const usePlayerStore = create((set, get) => ({
activeTool: "select",
setActiveTool: (tool) => set({ activeTool: tool }),
- ...createKeyframeSlice(set),
+ ...createKeyframeSlice(set, get),
activeKeyframePct: null,
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
@@ -533,6 +534,7 @@ export const usePlayerStore = create((set, get) => ({
selectedElementIds,
activeKeyframePct: null,
motionPathArmed: false,
+ focusedEaseSegment: null,
}
: { selectedElementId: id, selectedElementIds };
}),
@@ -542,9 +544,16 @@ export const usePlayerStore = create((set, get) => ({
setSelectionAnchor: (id) =>
set((s) => {
if (id != null && s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)) {
- return { selectedElementId: id };
+ return {
+ selectedElementId: id,
+ focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null,
+ };
}
- return { selectedElementId: id, selectedElementIds: id ? new Set([id]) : new Set() };
+ return {
+ selectedElementId: id,
+ selectedElementIds: id ? new Set([id]) : new Set(),
+ focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null,
+ };
}),
updateElement: (elementId, updates) =>
set((state) => ({
@@ -567,11 +576,6 @@ export const usePlayerStore = create((set, get) => ({
reset: () => set(createTimelineResetState()),
}));
-// Bug-bash aid: expose the store so a reproduction can dump live state from the
-// console, e.g. `__playerStore.getState().selectedElementId`. Harmless read
-// handle; no behavioural effect.
-// Only in dev. `import.meta.env` may be undefined in non-Vite bundlers (Next.js
-// Turbopack), so guard the access like the telemetry client does.
function isDevBuild(): boolean {
try {
return import.meta.env.DEV === true;