Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,20 +23,27 @@ vi.mock("./AnimationCard", () => ({
animation: GsapAnimation;
focusedSegment: { tweenPercentage: number } | null;
onFocusSegmentConsumed: () => void;
}) => (
<button
type="button"
data-testid={`animation-${animation.id}`}
data-focused={focusedSegment ? String(focusedSegment.tweenPercentage) : ""}
onClick={onFocusSegmentConsumed}
/>
),
}) => {
animationCardMock.consumers.push({
tweenPercentage: focusedSegment?.tweenPercentage ?? null,
consume: onFocusSegmentConsumed,
});
return (
<button
type="button"
data-testid={`animation-${animation.id}`}
data-focused={focusedSegment ? String(focusedSegment.tweenPercentage) : ""}
onClick={onFocusSegmentConsumed}
/>
);
},
}));

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

afterEach(() => {
document.body.innerHTML = "";
animationCardMock.consumers = [];
usePlayerStore.getState().reset();
});

Expand Down Expand Up @@ -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,
Expand All @@ -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<HTMLButtonElement>(
"[data-testid='animation-shared-animation']",
);
expect(card?.dataset.focused).toBe("");
expect(usePlayerStore.getState().focusedEaseSegment).toBe(staleRequest);
act(() => view.root.unmount());
});
});
34 changes: 23 additions & 11 deletions packages/studio/src/components/editor/GsapAnimationSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -20,8 +21,8 @@ interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
}

export const GsapAnimationSection = memo(function GsapAnimationSection({
animations,
elementId,
animations,
multipleTimelines,
unsupportedTimelinePattern,
onAddAnimation,
Expand All @@ -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 (
<Section title="Animation" icon={<Film size={15} />}>
Expand All @@ -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);
}
}}
/>
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -568,8 +568,8 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onUpdateArcSegment={onUpdateArcSegment}
onUnroll={onUnroll}
onUpdateKeyframeEase={onUpdateKeyframeEase}
onSetAllKeyframeEases={onSetAllKeyframeEases}
onUpdateSegmentEase={onUpdateSegmentEase}
onSetAllKeyframeEases={onSetAllKeyframeEases}
/>
)}

Expand Down
63 changes: 31 additions & 32 deletions packages/studio/src/components/editor/PropertyPanelFlat.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<typeof resolveEditingSections>;

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).
Expand Down Expand Up @@ -184,8 +165,8 @@ export function PropertyPanelFlat({
| "onUpdateArcSegment"
| "onUnroll"
| "onUpdateKeyframeEase"
| "onSetAllKeyframeEases"
| "onUpdateSegmentEase"
| "onSetAllKeyframeEases"
| "recordingState"
| "recordingDuration"
| "onToggleRecording"
Expand Down Expand Up @@ -263,18 +244,36 @@ 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):
// a stale panel would otherwise consume a focus request meant for its
// 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<string[]>([]);
const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
import type { resolveEditingSections } from "@hyperframes/core/editing";

export type EditingSections = ReturnType<typeof resolveEditingSections>;

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: () => {},
};
Loading
Loading