mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
fix(studio): scope timeline ease focus lifecycle (#2710)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-2">
|
||||
{animations.map((animation, index) => (
|
||||
<AnimationCard
|
||||
{...trackedCallbacks}
|
||||
key={animation.id}
|
||||
animation={animation}
|
||||
defaultExpanded={index === 0}
|
||||
flat={variant === "flat"}
|
||||
focusedSegment={focusedHere?.animationId === animation.id ? focusedHere : null}
|
||||
onFocusSegmentConsumed={consumeFocusedEaseSegment}
|
||||
/>
|
||||
))}
|
||||
<GsapAddAnimationControl
|
||||
open={addMenuOpen}
|
||||
setOpen={setAddMenuOpen}
|
||||
onAddAnimation={onAddAnimation}
|
||||
track={track}
|
||||
variant={variant}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`animation-${animation.id}`}
|
||||
data-focused={focusedSegment ? String(focusedSegment.tweenPercentage) : ""}
|
||||
// Mirrors the real card: the consume callback only fires from the effect
|
||||
// that runs when this card actually received a focusedSegment.
|
||||
onClick={() => focusedSegment && 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();
|
||||
});
|
||||
|
||||
@@ -76,8 +85,22 @@ function renderSection(elementId: string) {
|
||||
return { host, root, render };
|
||||
}
|
||||
|
||||
function focusSharedAnimation(elementId: string, tweenPercentage: number) {
|
||||
const store = usePlayerStore.getState();
|
||||
store.beginTimelineSession("project-a");
|
||||
store.setSelectedElementId(elementId);
|
||||
store.setFocusedEaseSegment({
|
||||
elementId,
|
||||
animationId: sharedAnimation.id,
|
||||
tweenPercentage,
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -99,4 +122,70 @@ describe("GsapAnimationSection", () => {
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("does not let an older consumer clear a newer request", () => {
|
||||
const store = focusSharedAnimation("index.html#first", 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("keeps the focus consumer stable across unrelated parent renders", () => {
|
||||
focusSharedAnimation("index.html#first", 25);
|
||||
const view = renderSection("index.html#first");
|
||||
const firstConsumer = animationCardMock.consumers.at(-1)?.consume;
|
||||
if (!firstConsumer) throw new Error("expected focus consumer");
|
||||
|
||||
view.render("index.html#first");
|
||||
|
||||
expect(animationCardMock.consumers.at(-1)?.consume).toBe(firstConsumer);
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { memo, useState } from "react";
|
||||
import { memo } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { Film } from "../../icons/SystemIcons";
|
||||
import { Section } from "./propertyPanelPrimitives";
|
||||
import { AnimationCard } from "./AnimationCard";
|
||||
import {
|
||||
type GsapAnimationEditCallbacks,
|
||||
withTrackedGsapAnimationCallbacks,
|
||||
clearFocusedEaseSegment,
|
||||
} from "./gsapAnimationCallbacks";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
|
||||
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
|
||||
import { GsapAnimationList } from "./GsapAnimationList";
|
||||
|
||||
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
|
||||
elementId: string;
|
||||
@@ -21,18 +14,13 @@ interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
|
||||
}
|
||||
|
||||
export const GsapAnimationSection = memo(function GsapAnimationSection({
|
||||
animations,
|
||||
elementId,
|
||||
animations,
|
||||
multipleTimelines,
|
||||
unsupportedTimelinePattern,
|
||||
onAddAnimation,
|
||||
...callbacks
|
||||
}: GsapAnimationSectionProps) {
|
||||
const track = useTrackDesignInput();
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
|
||||
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
||||
|
||||
return (
|
||||
<Section title="Animation" icon={<Film size={15} />}>
|
||||
{multipleTimelines && (
|
||||
@@ -49,31 +37,13 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
|
||||
</p>
|
||||
)}
|
||||
{multipleTimelines || unsupportedTimelinePattern ? null : (
|
||||
<div className="space-y-2">
|
||||
{animations.map((anim, index) => (
|
||||
<AnimationCard
|
||||
{...trackedCallbacks}
|
||||
key={anim.id}
|
||||
animation={anim}
|
||||
defaultExpanded={index === 0}
|
||||
focusedSegment={
|
||||
focusedEaseSegment?.elementId === elementId &&
|
||||
focusedEaseSegment.animationId === anim.id
|
||||
? focusedEaseSegment
|
||||
: null
|
||||
}
|
||||
onFocusSegmentConsumed={clearFocusedEaseSegment}
|
||||
/>
|
||||
))}
|
||||
|
||||
<GsapAddAnimationControl
|
||||
open={addMenuOpen}
|
||||
setOpen={setAddMenuOpen}
|
||||
onAddAnimation={onAddAnimation}
|
||||
track={track}
|
||||
variant="classic"
|
||||
/>
|
||||
</div>
|
||||
<GsapAnimationList
|
||||
{...callbacks}
|
||||
elementId={elementId}
|
||||
animations={animations}
|
||||
onAddAnimation={onAddAnimation}
|
||||
variant="classic"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -570,8 +570,8 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
onUpdateArcSegment={onUpdateArcSegment}
|
||||
onUnroll={onUnroll}
|
||||
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
||||
onSetAllKeyframeEases={onSetAllKeyframeEases}
|
||||
onUpdateSegmentEase={onUpdateSegmentEase}
|
||||
onSetAllKeyframeEases={onSetAllKeyframeEases}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
|
||||
import { slugifyDesignInput } from "../../utils/designInputTracking";
|
||||
import { isTextEditableSelection } from "./domEditing";
|
||||
@@ -18,6 +19,7 @@ import { createGsapLivePreview } from "./gsapLivePreview";
|
||||
import { formatTextFieldPreview } from "./propertyPanelSections";
|
||||
import { useColorGradingController } from "./useColorGradingController";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { isFocusedEaseRequestCurrent } from "../../player/store/keyframeSlice";
|
||||
import {
|
||||
FlatColorGradingAccessory,
|
||||
FlatColorGradingSection,
|
||||
@@ -31,24 +33,10 @@ import {
|
||||
deriveMediaOverlayPlacement,
|
||||
FlatOverlaysSection,
|
||||
} from "./propertyPanelFlatOverlaysSection";
|
||||
|
||||
type FlatGroupDescriptor = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
accessory?: ReactNode;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
// Required callback shape for the gated-off Motion effect list.
|
||||
const EMPTY_GSAP_EFFECT_HANDLERS = {
|
||||
onAddAnimation: () => {},
|
||||
onUpdateProperty: () => {},
|
||||
onUpdateMeta: () => {},
|
||||
onDeleteAnimation: () => {},
|
||||
onAddProperty: () => {},
|
||||
onRemoveProperty: () => {},
|
||||
};
|
||||
import {
|
||||
EMPTY_GSAP_EFFECT_HANDLERS,
|
||||
type FlatGroupDescriptor,
|
||||
} from "./propertyPanelFlatDescriptors";
|
||||
|
||||
/** The flat inspector shell with one shared open-group state. */
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -161,11 +149,18 @@ export function PropertyPanelFlat({
|
||||
// When the inline timeline ease button focuses a segment on this element,
|
||||
// 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);
|
||||
// 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 { focusedEaseSegment, timelineProjectId, timelineSessionEpoch } = usePlayerStore(
|
||||
useShallow((state) => ({
|
||||
focusedEaseSegment: state.focusedEaseSegment,
|
||||
timelineProjectId: state.timelineProjectId,
|
||||
timelineSessionEpoch: state.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 = scopedElementKey(element);
|
||||
// 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
|
||||
@@ -174,8 +169,16 @@ export function PropertyPanelFlat({
|
||||
if (focusedEaseSegment !== consumedFocus) {
|
||||
setConsumedFocus(focusedEaseSegment);
|
||||
const focusesThisPanel =
|
||||
focusedEaseSegment?.elementId === renderedElementId &&
|
||||
gsapAnimations.some((a) => a.id === focusedEaseSegment.animationId);
|
||||
focusedEaseSegment !== null &&
|
||||
// A request from a previous project/session/selection is stale: it must
|
||||
// not reopen Motion on whichever panel happens to be mounted now.
|
||||
isFocusedEaseRequestCurrent(focusedEaseSegment, {
|
||||
timelineProjectId,
|
||||
timelineSessionEpoch,
|
||||
selectedElementId,
|
||||
}) &&
|
||||
focusedEaseSegment.elementId === renderedElementId &&
|
||||
gsapAnimations.some((animation) => animation.id === focusedEaseSegment.animationId);
|
||||
if (focusesThisPanel) setOpenGroupId("motion");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
||||
|
||||
/**
|
||||
@@ -164,12 +163,3 @@ 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
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: () => {},
|
||||
};
|
||||
@@ -273,13 +273,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,
|
||||
@@ -287,55 +288,55 @@ it("forwards focused bulk segment easing through the flat animation card", () =>
|
||||
{ animationId: "a1", tweenPercentage: 50 },
|
||||
{ animationId: "a2", tweenPercentage: 75 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const { host, root } = renderInto(
|
||||
<FlatMotionSection
|
||||
element={baseElement()}
|
||||
animations={[
|
||||
{
|
||||
id: "a1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 1,
|
||||
properties: { x: 100 },
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 } },
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
]}
|
||||
showTiming={false}
|
||||
showEffects
|
||||
onSetAttribute={vi.fn()}
|
||||
onAddAnimation={vi.fn()}
|
||||
onUpdateProperty={vi.fn()}
|
||||
onUpdateMeta={vi.fn()}
|
||||
onDeleteAnimation={vi.fn()}
|
||||
onAddProperty={vi.fn()}
|
||||
onRemoveProperty={vi.fn()}
|
||||
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
||||
onUpdateSegmentEase={onUpdateSegmentEase}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
|
||||
expect(dropdown).not.toBeNull();
|
||||
act(() => dropdown?.click());
|
||||
const preset = host.querySelector<HTMLButtonElement>('[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());
|
||||
});
|
||||
const { host, root } = renderInto(
|
||||
<FlatMotionSection
|
||||
element={baseElement()}
|
||||
animations={[
|
||||
{
|
||||
id: "a1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 1,
|
||||
properties: { x: 100 },
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 } },
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
]}
|
||||
showTiming={false}
|
||||
showEffects
|
||||
onSetAttribute={vi.fn()}
|
||||
onAddAnimation={vi.fn()}
|
||||
onUpdateProperty={vi.fn()}
|
||||
onUpdateMeta={vi.fn()}
|
||||
onDeleteAnimation={vi.fn()}
|
||||
onAddProperty={vi.fn()}
|
||||
onRemoveProperty={vi.fn()}
|
||||
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
||||
onUpdateSegmentEase={onUpdateSegmentEase}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
|
||||
expect(dropdown).not.toBeNull();
|
||||
act(() => dropdown?.click());
|
||||
const preset = host.querySelector<HTMLButtonElement>('[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());
|
||||
});
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
|
||||
import { useState } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
||||
import { parseTimingValue } from "./propertyPanelTimingSection";
|
||||
import { CommitField } from "./propertyPanelPrimitives";
|
||||
import { AnimationCard } from "./AnimationCard";
|
||||
import {
|
||||
type GsapAnimationEditCallbacks,
|
||||
withTrackedGsapAnimationCallbacks,
|
||||
clearFocusedEaseSegment,
|
||||
} from "./gsapAnimationCallbacks";
|
||||
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
|
||||
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
|
||||
import { GsapAnimationList } from "./GsapAnimationList";
|
||||
|
||||
export function FlatTimingRow({
|
||||
element,
|
||||
@@ -136,19 +129,11 @@ export function FlatMotionSection({
|
||||
onSetAttributes?: (selection: DomEditSelection, attrs: Record<string, string>) => Promise<void>;
|
||||
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||
} & GsapAnimationEditCallbacks) {
|
||||
const track = useTrackDesignInput();
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
|
||||
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
||||
// 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 = scopedElementKey(element);
|
||||
const focusedHere =
|
||||
focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId
|
||||
? focusedEaseSegment
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -174,26 +159,13 @@ export function FlatMotionSection({
|
||||
</p>
|
||||
)}
|
||||
{!multipleTimelines && !unsupportedTimelinePattern && (
|
||||
<div className="space-y-2">
|
||||
{animations.map((anim, index) => (
|
||||
<AnimationCard
|
||||
{...trackedCallbacks}
|
||||
key={anim.id}
|
||||
animation={anim}
|
||||
defaultExpanded={index === 0}
|
||||
flat
|
||||
focusedSegment={focusedHere?.animationId === anim.id ? focusedHere : null}
|
||||
onFocusSegmentConsumed={clearFocusedEaseSegment}
|
||||
/>
|
||||
))}
|
||||
<GsapAddAnimationControl
|
||||
open={addMenuOpen}
|
||||
setOpen={setAddMenuOpen}
|
||||
onAddAnimation={onAddAnimation}
|
||||
track={track}
|
||||
variant="flat"
|
||||
/>
|
||||
</div>
|
||||
<GsapAnimationList
|
||||
{...callbacks}
|
||||
elementId={renderedElementId}
|
||||
animations={animations}
|
||||
onAddAnimation={onAddAnimation}
|
||||
variant="flat"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -123,8 +123,8 @@ export interface PropertyPanelProps {
|
||||
) => void;
|
||||
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
|
||||
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
|
||||
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
|
||||
onUpdateSegmentEase?: NonNullable<GsapAnimationEditCallbacks["onUpdateSegmentEase"]>;
|
||||
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
|
||||
onConvertToKeyframes?: (animationId: string, duration?: number) => void;
|
||||
onCommitAnimatedProperty?: (
|
||||
selection: DomEditSelection,
|
||||
|
||||
Reference in New Issue
Block a user