import { memo, useCallback, useMemo, useState } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants"; import { RESPONSIVE_GRID } from "./propertyPanelHelpers"; import { MetricField, SelectField } from "./propertyPanelPrimitives"; import { controlPointsForGsapEase } from "./studioMotion"; import { EASE_LABELS, METHOD_LABELS, METHOD_TOOLTIPS, PROP_LABELS } from "./gsapAnimationConstants"; import { buildTweenSummary } from "./gsapAnimationHelpers"; import { EaseCurveSection } from "./EaseCurveSection"; import { ArcPathControls } from "./ArcPathControls"; import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks"; import { ComputedTweenNotice } from "./ComputedTweenNotice"; import { KeyframeEaseList } from "./KeyframeEaseList"; import { PropertyRow, AddPropertyTrigger, parseNumericOrString, BOOLEAN_PROPS, } from "./AnimationCardParts"; interface AnimationCardProps extends GsapAnimationEditCallbacks { animation: GsapAnimation; defaultExpanded: boolean; } // fallow-ignore-next-line complexity export const AnimationCard = memo(function AnimationCard({ animation, defaultExpanded, onUpdateProperty, onUpdateMeta, onDeleteAnimation, onAddProperty, onRemoveProperty, onUpdateFromProperty, onAddFromProperty, onRemoveFromProperty, onLivePreview, onLivePreviewEnd, onSetArcPath, onUpdateArcSegment, onUpdateKeyframeEase, onUnroll, }: AnimationCardProps) { const [expanded, setExpanded] = useState(defaultExpanded); const [addingProp, setAddingProp] = useState(false); const [addingFromProp, setAddingFromProp] = useState(false); const [expandedKfPct, setExpandedKfPct] = useState(null); const usedProps = useMemo( () => new Set(Object.keys(animation.properties)), [animation.properties], ); const availableProps = useMemo( () => SUPPORTED_PROPS.filter( (p) => !usedProps.has(p) && (animation.method === "set" || !BOOLEAN_PROPS.has(p)), ), [usedProps, animation.method], ); const usedFromProps = useMemo( () => new Set(Object.keys(animation.fromProperties ?? {})), [animation.fromProperties], ); const availableFromProps = useMemo( () => SUPPORTED_PROPS.filter((p) => !usedFromProps.has(p) && !BOOLEAN_PROPS.has(p)), [usedFromProps], ); const commitProperty = useCallback( (prop: string, raw: string) => { const value = parseNumericOrString(raw); onUpdateProperty(animation.id, prop, value); onLivePreviewEnd?.(); }, [animation.id, onUpdateProperty, onLivePreviewEnd], ); const scrubProperty = useCallback( (prop: string, raw: string) => { onLivePreview?.(prop, parseNumericOrString(raw)); }, [onLivePreview], ); const commitFromProperty = useCallback( (prop: string, raw: string) => { const value = parseNumericOrString(raw); onUpdateFromProperty?.(animation.id, prop, value); onLivePreviewEnd?.(); }, [animation.id, onUpdateFromProperty, onLivePreviewEnd], ); const commitDuration = useCallback( (raw: string) => { const num = Number(raw); if (Number.isFinite(num) && num >= 0) onUpdateMeta(animation.id, { duration: Math.max(0, num) }); }, [animation.id, onUpdateMeta], ); const commitPosition = useCallback( (raw: string) => { const num = Number(raw); if (Number.isFinite(num) && num >= 0) onUpdateMeta(animation.id, { position: Math.max(0, num) }); }, [animation.id, onUpdateMeta], ); const [copied, setCopied] = useState(false); const methodLabel = METHOD_LABELS[animation.method] ?? animation.method; const easeName = (animation.keyframes ? animation.keyframes.easeEach : undefined) ?? animation.ease ?? "none"; const easeLabel = easeName.startsWith("custom(") ? "Custom curve" : (EASE_LABELS[easeName] ?? easeName); const endTime = typeof animation.position === "number" ? animation.position + (animation.duration ?? 0) : animation.position; const summary = useMemo(() => buildTweenSummary(animation), [animation]); const setKeys = Object.keys(animation.properties); if ( animation.method === "set" && // `every` is vacuously true on an empty bag — require at least one key so a // property-less set doesn't masquerade as a position row. (setKeys.includes("x") || setKeys.includes("y")) && setKeys.every((k) => k === "x" || k === "y" || k === "immediateRender") ) return (
Position x: {Math.round(Number(animation.properties.x ?? 0))}, y:{" "} {Math.round(Number(animation.properties.y ?? 0))} drag to move
); return (
{expanded && (
onUnroll(animation.id) : undefined} />

{summary}

{animation.keyframes && (

Keyframed — click a segment below to edit its curve

)}
{animation.method !== "set" && ( )}
{animation.method !== "set" && ( <> {animation.keyframes && onUpdateKeyframeEase ? ( onUpdateKeyframeEase(animation.id, pct, ease)} /> ) : ( <> { const easeKey = animation.keyframes ? "easeEach" : "ease"; if (next === "custom") { const points = controlPointsForGsapEase( easeName !== "none" ? easeName : "power2.out", ); const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`; onUpdateMeta(animation.id, { [easeKey]: `custom(${path})` }); } else { onUpdateMeta(animation.id, { [easeKey]: next }); } }} /> { const easeKey = animation.keyframes ? "easeEach" : "ease"; onUpdateMeta(animation.id, { [easeKey]: customEase }); }} /> )} )} {animation.method === "fromTo" && (

From

{Object.entries(animation.fromProperties ?? {}).map(([prop, val]) => ( commitFromProperty(prop, adjusted)} onRemove={() => onRemoveFromProperty?.(animation.id, prop)} removeTitle={`Remove from-${PROP_LABELS[prop] ?? prop}`} /> ))}
onAddFromProperty?.(animation.id, prop)} onOpen={() => setAddingFromProp(true)} onClose={() => setAddingFromProp(false)} buttonClassName="text-[11px] font-medium text-orange-400/70 transition-colors hover:text-orange-300" />
)} {animation.method === "fromTo" && Object.keys(animation.properties).length > 0 && (

To

)} {Object.keys(animation.properties).length > 0 && (
{Object.entries(animation.properties).map(([prop, val]) => ( { scrubProperty(prop, adjusted); commitProperty(prop, adjusted); }} onRemove={() => onRemoveProperty(animation.id, prop)} removeTitle={`Remove ${PROP_LABELS[prop] ?? prop}`} /> ))}
)} {onSetArcPath && (animation.properties.x != null || animation.properties.y != null || animation.keyframes) && (
onSetArcPath(animation.id, { enabled, segments: animation.arcPath?.segments, }) } onUpdateSegment={(index, update) => onUpdateArcSegment?.(animation.id, index, update) } onToggleAutoRotate={(autoRotate) => onSetArcPath(animation.id, { enabled: true, autoRotate, segments: animation.arcPath?.segments, }) } />
)}
onAddProperty(animation.id, prop)} onOpen={() => setAddingProp(true)} onClose={() => setAddingProp(false)} buttonClassName="text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200" />
)}
); });