feat(studio): consolidate timeline editor callbacks

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-27 19:52:06 +02:00
parent 555d7f7a5c
commit 2d1b905a56
14 changed files with 959 additions and 342 deletions
@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants";
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
@@ -23,6 +23,8 @@ interface AnimationCardProps extends GsapAnimationEditCallbacks {
animation: GsapAnimation;
defaultExpanded: boolean;
flat?: boolean;
focusedSegment?: { tweenPercentage: number } | null;
onFocusSegmentConsumed?: () => void;
}
// fallow-ignore-next-line complexity
@@ -30,6 +32,8 @@ export const AnimationCard = memo(function AnimationCard({
animation,
defaultExpanded,
flat,
focusedSegment,
onFocusSegmentConsumed,
onUpdateProperty,
onUpdateMeta,
onDeleteAnimation,
@@ -50,6 +54,25 @@ export const AnimationCard = memo(function AnimationCard({
const [addingProp, setAddingProp] = useState(false);
const [addingFromProp, setAddingFromProp] = useState(false);
const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null);
const cardRef = useRef<HTMLDivElement>(null);
const pendingAutoScrollRef = useRef(false);
useEffect(() => {
if (!focusedSegment) return;
setExpanded(true);
pendingAutoScrollRef.current = true;
setExpandedKfPct(focusedSegment.tweenPercentage);
onFocusSegmentConsumed?.();
}, [focusedSegment, onFocusSegmentConsumed]);
useEffect(() => {
if (!pendingAutoScrollRef.current || expandedKfPct === null) return;
const segment = cardRef.current?.querySelector<HTMLElement>(
`[data-ease-segment-pct="${expandedKfPct}"]`,
);
segment?.scrollIntoView({ block: "nearest", behavior: "smooth" });
pendingAutoScrollRef.current = false;
}, [expandedKfPct]);
const usedProps = useMemo(
() => new Set(Object.keys(animation.properties)),
@@ -154,6 +177,7 @@ export const AnimationCard = memo(function AnimationCard({
return (
<div
ref={cardRef}
data-flat-effect-card={flat ? "true" : undefined}
className={
flat
@@ -0,0 +1,68 @@
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
const STYLES = {
classic: {
method:
"rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 py-1.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white",
cancel: "px-1.5 text-[11px] text-neutral-500 hover:text-neutral-300",
trigger: "text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200",
},
flat: {
method:
"rounded-lg border border-panel-border-input bg-panel-input px-2.5 py-1.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:border-panel-text-4 hover:text-panel-text-0",
cancel: "px-1.5 text-[11px] text-panel-text-3 hover:text-panel-text-1",
trigger: "text-[11px] font-medium text-panel-text-3 transition-colors hover:text-panel-text-1",
},
};
export function GsapAddAnimationControl({
open,
setOpen,
onAddAnimation,
track,
variant,
}: {
open: boolean;
setOpen: (open: boolean) => void;
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
track: (control: string, name: string) => void;
variant: keyof typeof STYLES;
}) {
const styles = STYLES[variant];
return (
<div className="relative pt-1">
{open ? (
<div className="flex gap-1.5">
{ADD_METHODS.map((method) => (
<button
key={method}
type="button"
title={METHOD_TOOLTIPS[method]}
onClick={() => {
track("button", `Add ${method} animation`);
onAddAnimation(method);
setOpen(false);
}}
className={styles.method}
>
{ADD_METHOD_LABELS[method] ?? method}
</button>
))}
<button type="button" onClick={() => setOpen(false)} className={styles.cancel}>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setOpen(true)}
className={styles.trigger}
title="Add a new animation effect to this element"
>
+ Add effect
</button>
)}
</div>
);
}
@@ -2,13 +2,14 @@ import { memo, useState } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { Film } from "../../icons/SystemIcons";
import { Section } from "./propertyPanelPrimitives";
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
import { AnimationCard } from "./AnimationCard";
import {
trackAnimationMetaUpdate,
type GsapAnimationEditCallbacks,
withTrackedGsapAnimationCallbacks,
} from "./gsapAnimationCallbacks";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import { usePlayerStore } from "../../player";
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
animations: GsapAnimation[];
@@ -21,41 +22,14 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
animations,
multipleTimelines,
unsupportedTimelinePattern,
onUpdateProperty,
onUpdateMeta,
onDeleteAnimation,
onAddProperty,
onRemoveProperty,
onUpdateFromProperty,
onAddFromProperty,
onRemoveFromProperty,
onAddAnimation,
onLivePreview,
onLivePreviewEnd,
onSetArcPath,
onUpdateArcSegment,
onUpdateKeyframeEase,
onSetAllKeyframeEases,
onUnroll,
...callbacks
}: GsapAnimationSectionProps) {
const track = useTrackDesignInput();
const [addMenuOpen, setAddMenuOpen] = useState(false);
const trackProperty = (property: string) => {
const control =
property === "visibility"
? "toggle"
: property === "filter" || property === "clipPath"
? "text"
: "metric";
track(control, property);
};
const updateMeta = (
animationId: string,
updates: { duration?: number; ease?: string; position?: number },
) => {
trackAnimationMetaUpdate(track, updates);
onUpdateMeta(animationId, updates);
};
const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
const setFocusedEaseSegment = usePlayerStore((s) => s.setFocusedEaseSegment);
return (
<Section title="Animation" icon={<Film size={15} />}>
@@ -76,137 +50,24 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
<div className="space-y-2">
{animations.map((anim, index) => (
<AnimationCard
{...trackedCallbacks}
key={anim.id}
animation={anim}
defaultExpanded={index === 0}
onUpdateProperty={(animationId, property, value) => {
trackProperty(property);
onUpdateProperty(animationId, property, value);
}}
onUpdateMeta={updateMeta}
onDeleteAnimation={(animationId) => {
track("button", "Remove animation");
onDeleteAnimation(animationId);
}}
onAddProperty={(animationId, property) => {
track("select", "Add effect property");
onAddProperty(animationId, property);
}}
onRemoveProperty={(animationId, property) => {
track("button", `Remove ${property}`);
onRemoveProperty(animationId, property);
}}
onUpdateFromProperty={
onUpdateFromProperty
? (animationId, property, value) => {
trackProperty(property);
onUpdateFromProperty(animationId, property, value);
}
: undefined
}
onAddFromProperty={
onAddFromProperty
? (animationId, property) => {
track("select", "Add from property");
onAddFromProperty(animationId, property);
}
: undefined
}
onRemoveFromProperty={
onRemoveFromProperty
? (animationId, property) => {
track("button", `Remove from ${property}`);
onRemoveFromProperty(animationId, property);
}
: undefined
}
onLivePreview={onLivePreview}
onLivePreviewEnd={onLivePreviewEnd}
onSetArcPath={
onSetArcPath
? (animationId, config) => {
track(
"toggle",
config.autoRotate !== undefined ? "Auto rotate" : "Arc motion",
);
onSetArcPath(animationId, config);
}
: undefined
}
onUpdateArcSegment={
onUpdateArcSegment
? (animationId, segmentIndex, update) => {
if (update.curviness === undefined) {
track("button", `Reset arc segment ${segmentIndex + 1}`);
}
onUpdateArcSegment(animationId, segmentIndex, update);
}
: undefined
}
onUpdateKeyframeEase={
onUpdateKeyframeEase
? (animationId, percentage, ease) => {
track("select", "Keyframe ease");
onUpdateKeyframeEase(animationId, percentage, ease);
}
: undefined
}
onSetAllKeyframeEases={
onSetAllKeyframeEases
? (animationId, ease) => {
track("select", "All keyframe eases");
onSetAllKeyframeEases(animationId, ease);
}
: undefined
}
onUnroll={
onUnroll
? (animationId) => {
track("button", "Unroll animation");
onUnroll(animationId);
}
: undefined
focusedSegment={
focusedEaseSegment?.animationId === anim.id ? focusedEaseSegment : null
}
onFocusSegmentConsumed={() => setFocusedEaseSegment(null)}
/>
))}
<div className="relative pt-1">
{addMenuOpen ? (
<div className="flex gap-1.5">
{ADD_METHODS.map((method) => (
<button
key={method}
type="button"
title={METHOD_TOOLTIPS[method]}
onClick={() => {
track("button", `Add ${method} animation`);
onAddAnimation(method);
setAddMenuOpen(false);
}}
className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 py-1.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white"
>
{ADD_METHOD_LABELS[method] ?? method}
</button>
))}
<button
type="button"
onClick={() => setAddMenuOpen(false)}
className="px-1.5 text-[11px] text-neutral-500 hover:text-neutral-300"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setAddMenuOpen(true)}
className="text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200"
title="Add a new animation effect to this element"
>
+ Add effect
</button>
)}
</div>
<GsapAddAnimationControl
open={addMenuOpen}
setOpen={setAddMenuOpen}
onAddAnimation={onAddAnimation}
track={track}
variant="classic"
/>
</div>
)}
</Section>
@@ -19,6 +19,7 @@ import { createGsapLivePreview } from "./gsapLivePreview";
import { formatTextFieldPreview } from "./propertyPanelSections";
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
import { useColorGradingController } from "./useColorGradingController";
import { usePlayerStore } from "../../player";
import {
FlatColorGradingAccessory,
FlatColorGradingSection,
@@ -231,7 +232,36 @@ export function PropertyPanelFlat({
: "layout",
);
// Animate only the groups that changed during this toggle cycle.
// Tracks which group(s) are actively transitioning this toggle cycle, so
// their header/body gets the fast entrance animation (hf-flat-group-enter)
// and no one else's does. Deliberately NOT derived from remounting alone:
// FlatGroupHeader instances are keyed by group id and React normally
// preserves them across re-renders, but toggling a non-adjacent group still
// shifts the untouched collapsed siblings between the before/after-open
// slices below, and Chromium restarts a CSS animation on that kind of
// position shift even though nothing about the sibling actually changed.
// Gating on these ids (cleared shortly after the 120ms CSS animation
// finishes) keeps the animation scoped to only the groups that actually
// just toggled. Two ids, not one: the clicked (newly-opening/closing) group
// AND whichever group was open immediately before the click and got
// implicitly closed by it — both freshly-mounted headers need to animate.
// 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);
// 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]);
const [justToggledIds, setJustToggledIds] = useState<string[]>([]);
const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const panelBodyRef = useRef<HTMLDivElement>(null);
@@ -492,6 +522,17 @@ export function PropertyPanelFlat({
const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex);
const openGroup = openIndex === -1 ? null : groups[openIndex];
const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1);
const renderClosedGroup = (group: FlatGroupDescriptor) => (
<DesignPanelInputProvider key={group.id} section={slugifyDesignInput(group.title)}>
<FlatGroupHeader
title={group.title}
isOpen={false}
onToggleOpen={() => toggleOpen(group.id)}
summary={group.summary}
animateEntrance={justToggledIds.includes(group.id)}
/>
</DesignPanelInputProvider>
);
return (
<DesignPanelInputProvider ui="flat">
@@ -519,17 +560,7 @@ export function PropertyPanelFlat({
data-flat-panel-body="true"
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
>
{beforeOpen.map((g) => (
<DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
<FlatGroupHeader
title={g.title}
isOpen={false}
onToggleOpen={() => toggleOpen(g.id)}
summary={g.summary}
animateEntrance={justToggledIds.includes(g.id)}
/>
</DesignPanelInputProvider>
))}
{beforeOpen.map(renderClosedGroup)}
{openGroup && (
<DesignPanelInputProvider section={slugifyDesignInput(openGroup.title)}>
<div data-flat-group-open="true" className="flex min-h-[180px] flex-none flex-col">
@@ -548,17 +579,7 @@ export function PropertyPanelFlat({
</div>
</DesignPanelInputProvider>
)}
{afterOpen.map((g) => (
<DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
<FlatGroupHeader
title={g.title}
isOpen={false}
onToggleOpen={() => toggleOpen(g.id)}
summary={g.summary}
animateEntrance={justToggledIds.includes(g.id)}
/>
</DesignPanelInputProvider>
))}
{afterOpen.map(renderClosedGroup)}
</div>
<DesignPanelInputProvider section="footer">
<PropertyPanelFlatFooter
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from "vitest";
import {
type GsapAnimationEditCallbacks,
withTrackedGsapAnimationCallbacks,
} from "./gsapAnimationCallbacks";
function requiredCallbacks(): GsapAnimationEditCallbacks {
return {
onUpdateProperty: vi.fn(),
onUpdateMeta: vi.fn(),
onDeleteAnimation: vi.fn(),
onAddProperty: vi.fn(),
onRemoveProperty: vi.fn(),
};
}
function requireCallback<T>(callback: T | undefined): T {
if (callback === undefined) throw new Error("expected callback to be present");
return callback;
}
describe("withTrackedGsapAnimationCallbacks", () => {
it("keeps absent optional callbacks absent and passes preview callbacks through unchanged", () => {
const callbacks = requiredCallbacks();
const onLivePreview = vi.fn();
const onLivePreviewEnd = vi.fn();
callbacks.onLivePreview = onLivePreview;
callbacks.onLivePreviewEnd = onLivePreviewEnd;
const tracked = withTrackedGsapAnimationCallbacks(callbacks, vi.fn());
expect(tracked.onUpdateFromProperty).toBeUndefined();
expect(tracked.onAddFromProperty).toBeUndefined();
expect(tracked.onRemoveFromProperty).toBeUndefined();
expect(tracked.onSetArcPath).toBeUndefined();
expect(tracked.onUpdateArcSegment).toBeUndefined();
expect(tracked.onUpdateKeyframeEase).toBeUndefined();
expect(tracked.onSetAllKeyframeEases).toBeUndefined();
expect(tracked.onUnroll).toBeUndefined();
expect(tracked.onLivePreview).toBe(onLivePreview);
expect(tracked.onLivePreviewEnd).toBe(onLivePreviewEnd);
});
it("tracks each edit once before invoking its mutation callback", () => {
const events: string[] = [];
const mutation = (name: string) => () => events.push(`mutate:${name}`);
const callbacks: GsapAnimationEditCallbacks = {
onUpdateProperty: mutation("update-property"),
onUpdateMeta: mutation("update-meta"),
onDeleteAnimation: mutation("delete"),
onAddProperty: mutation("add-property"),
onRemoveProperty: mutation("remove-property"),
onUpdateFromProperty: mutation("update-from"),
onAddFromProperty: mutation("add-from"),
onRemoveFromProperty: mutation("remove-from"),
onSetArcPath: mutation("arc-path"),
onUpdateArcSegment: mutation("arc-segment"),
onUpdateKeyframeEase: mutation("keyframe-ease"),
onSetAllKeyframeEases: mutation("all-eases"),
onUnroll: mutation("unroll"),
};
const tracked = withTrackedGsapAnimationCallbacks(callbacks, (control, name) => {
events.push(`track:${control}:${name}`);
});
tracked.onUpdateProperty("a1", "visibility", 1);
tracked.onUpdateProperty("a1", "filter", "blur(2px)");
tracked.onUpdateProperty("a1", "opacity", 0.5);
tracked.onUpdateMeta("a1", { duration: 2, ease: "none", position: 1 });
tracked.onDeleteAnimation("a1");
tracked.onAddProperty("a1", "scale");
tracked.onRemoveProperty("a1", "scale");
requireCallback(tracked.onUpdateFromProperty)("a1", "clipPath", "none");
requireCallback(tracked.onAddFromProperty)("a1", "x");
requireCallback(tracked.onRemoveFromProperty)("a1", "x");
requireCallback(tracked.onSetArcPath)("a1", { enabled: true });
requireCallback(tracked.onSetArcPath)("a1", { enabled: true, autoRotate: true });
requireCallback(tracked.onUpdateArcSegment)("a1", 1, {});
requireCallback(tracked.onUpdateArcSegment)("a1", 1, { curviness: 0.5 });
requireCallback(tracked.onUpdateKeyframeEase)("a1", 50, "power2.out");
requireCallback(tracked.onSetAllKeyframeEases)("a1", "none");
requireCallback(tracked.onUnroll)("a1");
expect(events).toEqual([
"track:toggle:visibility",
"mutate:update-property",
"track:text:filter",
"mutate:update-property",
"track:metric:opacity",
"mutate:update-property",
"track:metric:Length",
"track:select:Speed",
"track:metric:Starts at",
"mutate:update-meta",
"track:button:Remove animation",
"mutate:delete",
"track:select:Add effect property",
"mutate:add-property",
"track:button:Remove scale",
"mutate:remove-property",
"track:text:clipPath",
"mutate:update-from",
"track:select:Add from property",
"mutate:add-from",
"track:button:Remove from x",
"mutate:remove-from",
"track:toggle:Arc motion",
"mutate:arc-path",
"track:toggle:Auto rotate",
"mutate:arc-path",
"track:button:Reset arc segment 2",
"mutate:arc-segment",
"mutate:arc-segment",
"track:select:Keyframe ease",
"mutate:keyframe-ease",
"track:select:All keyframe eases",
"mutate:all-eases",
"track:button:Unroll animation",
"mutate:unroll",
]);
});
});
@@ -35,6 +35,18 @@ export interface GsapAnimationEditCallbacks {
onUnroll?: (animationId: string) => void;
}
type TrackDesignInput = (control: string, name: string) => void;
function trackAnimationProperty(track: TrackDesignInput, property: string): void {
const control =
property === "visibility"
? "toggle"
: property === "filter" || property === "clipPath"
? "text"
: "metric";
track(control, property);
}
// User-facing control label for each animation-meta field. The ease control is
// labelled "Speed" in the card UI, so ease/easeEach map there.
const ANIMATION_META_LABELS: Record<string, { control: string; name: string }> = {
@@ -51,13 +63,95 @@ const ANIMATION_META_LABELS: Record<string, { control: string; name: string }> =
* added later is attributed honestly by its own key instead of poisoning another
* control's usage count.
*/
export function trackAnimationMetaUpdate(
track: (control: string, name: string) => void,
updates: Record<string, unknown>,
): void {
function trackAnimationMetaUpdate(track: TrackDesignInput, updates: Record<string, unknown>): void {
for (const key of Object.keys(updates)) {
const mapped = ANIMATION_META_LABELS[key];
if (mapped) track(mapped.control, mapped.name);
else track("select", key);
}
}
/**
* Add design-input telemetry to the shared animation-edit callback surface.
* Optional callbacks remain absent, pass-through preview callbacks keep their
* original identity, and every tracked event fires once before its mutation.
*/
export function withTrackedGsapAnimationCallbacks(
callbacks: GsapAnimationEditCallbacks,
track: TrackDesignInput,
): GsapAnimationEditCallbacks {
return {
onUpdateProperty: (animationId, property, value) => {
trackAnimationProperty(track, property);
callbacks.onUpdateProperty(animationId, property, value);
},
onUpdateMeta: (animationId, updates) => {
trackAnimationMetaUpdate(track, updates);
callbacks.onUpdateMeta(animationId, updates);
},
onDeleteAnimation: (animationId) => {
track("button", "Remove animation");
callbacks.onDeleteAnimation(animationId);
},
onAddProperty: (animationId, property) => {
track("select", "Add effect property");
callbacks.onAddProperty(animationId, property);
},
onRemoveProperty: (animationId, property) => {
track("button", `Remove ${property}`);
callbacks.onRemoveProperty(animationId, property);
},
onUpdateFromProperty: callbacks.onUpdateFromProperty
? (animationId, property, value) => {
trackAnimationProperty(track, property);
callbacks.onUpdateFromProperty?.(animationId, property, value);
}
: undefined,
onAddFromProperty: callbacks.onAddFromProperty
? (animationId, property) => {
track("select", "Add from property");
callbacks.onAddFromProperty?.(animationId, property);
}
: undefined,
onRemoveFromProperty: callbacks.onRemoveFromProperty
? (animationId, property) => {
track("button", `Remove from ${property}`);
callbacks.onRemoveFromProperty?.(animationId, property);
}
: undefined,
onLivePreview: callbacks.onLivePreview,
onLivePreviewEnd: callbacks.onLivePreviewEnd,
onSetArcPath: callbacks.onSetArcPath
? (animationId, config) => {
track("toggle", config.autoRotate !== undefined ? "Auto rotate" : "Arc motion");
callbacks.onSetArcPath?.(animationId, config);
}
: undefined,
onUpdateArcSegment: callbacks.onUpdateArcSegment
? (animationId, segmentIndex, update) => {
if (update.curviness === undefined) {
track("button", `Reset arc segment ${segmentIndex + 1}`);
}
callbacks.onUpdateArcSegment?.(animationId, segmentIndex, update);
}
: undefined,
onUpdateKeyframeEase: callbacks.onUpdateKeyframeEase
? (animationId, percentage, ease) => {
track("select", "Keyframe ease");
callbacks.onUpdateKeyframeEase?.(animationId, percentage, ease);
}
: undefined,
onSetAllKeyframeEases: callbacks.onSetAllKeyframeEases
? (animationId, ease) => {
track("select", "All keyframe eases");
callbacks.onSetAllKeyframeEases?.(animationId, ease);
}
: undefined,
onUnroll: callbacks.onUnroll
? (animationId) => {
track("button", "Unroll animation");
callbacks.onUnroll?.(animationId);
}
: undefined,
};
}
@@ -6,12 +6,13 @@ import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
import { parseTimingValue } from "./propertyPanelTimingSection";
import { CommitField } from "./propertyPanelPrimitives";
import { AnimationCard } from "./AnimationCard";
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
import {
trackAnimationMetaUpdate,
type GsapAnimationEditCallbacks,
withTrackedGsapAnimationCallbacks,
} from "./gsapAnimationCallbacks";
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
import { usePlayerStore } from "../../player";
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
export function FlatTimingRow({
element,
@@ -135,15 +136,18 @@ export function FlatMotionSection({
} & GsapAnimationEditCallbacks) {
const track = useTrackDesignInput();
const [addMenuOpen, setAddMenuOpen] = useState(false);
const trackProperty = (property: string) => {
const control =
property === "visibility"
? "toggle"
: property === "filter" || property === "clipPath"
? "text"
: "metric";
track(control, property);
};
const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
const setFocusedEaseSegment = usePlayerStore((s) => s.setFocusedEaseSegment);
// 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
: null;
return (
<div className="space-y-3">
@@ -172,140 +176,22 @@ export function FlatMotionSection({
<div className="space-y-2">
{animations.map((anim, index) => (
<AnimationCard
{...trackedCallbacks}
key={anim.id}
animation={anim}
defaultExpanded={index === 0}
flat
onUpdateProperty={(animationId, property, value) => {
trackProperty(property);
callbacks.onUpdateProperty(animationId, property, value);
}}
onUpdateMeta={(animationId, updates) => {
trackAnimationMetaUpdate(track, updates);
callbacks.onUpdateMeta(animationId, updates);
}}
onDeleteAnimation={(animationId) => {
track("button", "Remove animation");
callbacks.onDeleteAnimation(animationId);
}}
onAddProperty={(animationId, property) => {
track("select", "Add effect property");
callbacks.onAddProperty(animationId, property);
}}
onRemoveProperty={(animationId, property) => {
track("button", `Remove ${property}`);
callbacks.onRemoveProperty(animationId, property);
}}
onUpdateFromProperty={
callbacks.onUpdateFromProperty
? (animationId, property, value) => {
trackProperty(property);
callbacks.onUpdateFromProperty?.(animationId, property, value);
}
: undefined
}
onAddFromProperty={
callbacks.onAddFromProperty
? (animationId, property) => {
track("select", "Add from property");
callbacks.onAddFromProperty?.(animationId, property);
}
: undefined
}
onRemoveFromProperty={
callbacks.onRemoveFromProperty
? (animationId, property) => {
track("button", `Remove from ${property}`);
callbacks.onRemoveFromProperty?.(animationId, property);
}
: undefined
}
onLivePreview={callbacks.onLivePreview}
onLivePreviewEnd={callbacks.onLivePreviewEnd}
onSetArcPath={
callbacks.onSetArcPath
? (animationId, config) => {
track(
"toggle",
config.autoRotate !== undefined ? "Auto rotate" : "Arc motion",
);
callbacks.onSetArcPath?.(animationId, config);
}
: undefined
}
onUpdateArcSegment={
callbacks.onUpdateArcSegment
? (animationId, segmentIndex, update) => {
if (update.curviness === undefined) {
track("button", `Reset arc segment ${segmentIndex + 1}`);
}
callbacks.onUpdateArcSegment?.(animationId, segmentIndex, update);
}
: undefined
}
onUpdateKeyframeEase={
callbacks.onUpdateKeyframeEase
? (animationId, percentage, ease) => {
track("select", "Keyframe ease");
callbacks.onUpdateKeyframeEase?.(animationId, percentage, ease);
}
: undefined
}
onSetAllKeyframeEases={
callbacks.onSetAllKeyframeEases
? (animationId, ease) => {
track("select", "All keyframe eases");
callbacks.onSetAllKeyframeEases?.(animationId, ease);
}
: undefined
}
onUnroll={
callbacks.onUnroll
? (animationId) => {
track("button", "Unroll animation");
callbacks.onUnroll?.(animationId);
}
: undefined
}
focusedSegment={focusedHere?.animationId === anim.id ? focusedHere : null}
onFocusSegmentConsumed={() => setFocusedEaseSegment(null)}
/>
))}
<div className="relative pt-1">
{addMenuOpen ? (
<div className="flex gap-1.5">
{ADD_METHODS.map((method) => (
<button
key={method}
type="button"
title={METHOD_TOOLTIPS[method]}
onClick={() => {
track("button", `Add ${method} animation`);
onAddAnimation(method);
setAddMenuOpen(false);
}}
className="rounded-lg border border-panel-border-input bg-panel-input px-2.5 py-1.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:border-panel-text-4 hover:text-panel-text-0"
>
{ADD_METHOD_LABELS[method] ?? method}
</button>
))}
<button
type="button"
onClick={() => setAddMenuOpen(false)}
className="px-1.5 text-[11px] text-panel-text-3 hover:text-panel-text-1"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setAddMenuOpen(true)}
className="text-[11px] font-medium text-panel-text-3 transition-colors hover:text-panel-text-1"
title="Add a new animation effect to this element"
>
+ Add effect
</button>
)}
</div>
<GsapAddAnimationControl
open={addMenuOpen}
setOpen={setAddMenuOpen}
onAddAnimation={onAddAnimation}
track={track}
variant="flat"
/>
</div>
)}
</>