diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index b55aac305..68088311f 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -401,8 +401,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/GsapAnimationList.tsx b/packages/studio/src/components/editor/GsapAnimationList.tsx new file mode 100644 index 000000000..9a88e129f --- /dev/null +++ b/packages/studio/src/components/editor/GsapAnimationList.tsx @@ -0,0 +1,84 @@ +import { useCallback, useState } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { useShallow } from "zustand/react/shallow"; +import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; +import { usePlayerStore } from "../../player"; +import { isFocusedEaseRequestCurrent } from "../../player/store/keyframeSlice"; +import { AnimationCard } from "./AnimationCard"; +import { GsapAddAnimationControl } from "./GsapAddAnimationControl"; +import { + type GsapAnimationEditCallbacks, + withTrackedGsapAnimationCallbacks, +} from "./gsapAnimationCallbacks"; + +interface GsapAnimationListProps extends GsapAnimationEditCallbacks { + elementId: string; + animations: GsapAnimation[]; + onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; + variant: "classic" | "flat"; +} + +/** Shared animation cards, telemetry, and timeline-ease focus ownership for both inspectors. */ +export function GsapAnimationList({ + elementId, + animations, + onAddAnimation, + variant, + ...callbacks +}: GsapAnimationListProps) { + const track = useTrackDesignInput(); + const [addMenuOpen, setAddMenuOpen] = useState(false); + const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track); + const { focusedEaseSegment, timelineProjectId, timelineSessionEpoch, selectedElementId } = + usePlayerStore( + useShallow((state) => ({ + focusedEaseSegment: state.focusedEaseSegment, + timelineProjectId: state.timelineProjectId, + timelineSessionEpoch: state.timelineSessionEpoch, + selectedElementId: state.selectedElementId, + })), + ); + const focusedHere = + focusedEaseSegment && + focusedEaseSegment.elementId === elementId && + isFocusedEaseRequestCurrent(focusedEaseSegment, { + timelineProjectId, + timelineSessionEpoch, + selectedElementId, + }) && + animations.some((animation) => animation.id === focusedEaseSegment.animationId) + ? focusedEaseSegment + : null; + // Stable while the request is unchanged: AnimationCard includes this callback + // in its focus-effect deps, and a fresh closure would replay that effect on + // unrelated inspector renders. Reading the action lazily also avoids a store + // subscription for a function whose identity never changes. + const consumeFocusedEaseSegment = useCallback(() => { + if (focusedHere) { + usePlayerStore.getState().clearFocusedEaseSegment(focusedHere.nonce); + } + }, [focusedHere]); + + return ( +
+ {animations.map((animation, index) => ( + + ))} + +
+ ); +} diff --git a/packages/studio/src/components/editor/GsapAnimationSection.test.tsx b/packages/studio/src/components/editor/GsapAnimationSection.test.tsx index 7d299e158..8aa5c40ea 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,22 +23,27 @@ vi.mock("./AnimationCard", () => ({ animation: GsapAnimation; focusedSegment: { tweenPercentage: number } | null; onFocusSegmentConsumed: () => void; - }) => ( -