import { memo, useMemo } from "react"; import { X, Zap } from "../../icons/SystemIcons"; import type { DomEditSelection } from "./domEditing"; import { STUDIO_GSAP_EASE_OPTIONS, buildStudioGsapPresetMotion, controlPointsForGsapEase, parseStudioCustomEaseData, serializeStudioCustomEaseData, type StudioCustomEaseControlPoints, type StudioGsapMotion, type StudioGsapMotionDirection, type StudioGsapMotionPreset, } from "./studioMotion"; import { formatNumericValue, clampMotionNumber, parsePlainNumber, DetailField, SegmentedControl, SelectField, MotionSection, RESPONSIVE_GRID, } from "./MotionPanelFields"; import { EaseCurveEditor } from "./EaseCurveEditor"; /** Motion data without targeting metadata (kind/target/updatedAt are derived from context). */ type StudioMotionData = Omit; interface MotionPanelProps { element: DomEditSelection | null; motion: StudioMotionData | null; onClearSelection: () => void; onSetMotion: (element: DomEditSelection, motion: StudioMotionData) => void; onClearMotion: (element: DomEditSelection) => void; } const MOTION_PRESET_OPTIONS: Array<{ label: string; value: StudioGsapMotionPreset }> = [ { label: "Fade Up", value: "fade-up" }, { label: "Slide", value: "slide" }, { label: "Pop", value: "pop" }, ]; const MOTION_DIRECTION_OPTIONS: StudioGsapMotionDirection[] = ["up", "down", "left", "right"]; function motionValueDistance(motion: StudioMotionData | null): number { if (!motion) return 32; return Math.max(Math.abs(motion.from.x ?? 0), Math.abs(motion.from.y ?? 0), 1); } function inferMotionPreset(motion: StudioMotionData | null): StudioGsapMotionPreset { if (!motion) return "fade-up"; if (motion.from.scale != null || motion.to.scale != null) return "pop"; if (motion.from.x != null || motion.to.x != null) return "slide"; return "fade-up"; } function inferMotionDirection(motion: StudioMotionData | null): StudioGsapMotionDirection { if (!motion) return "up"; const x = motion.from.x ?? 0; const y = motion.from.y ?? 0; if (Math.abs(x) > Math.abs(y)) return x < 0 ? "right" : "left"; return y < 0 ? "down" : "up"; } function buildStudioCustomEaseId(element: DomEditSelection): string { const source = element.id || element.selector || element.label || "layer"; const normalized = source .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); return `studio-${normalized || "layer"}-ease`; } export const MotionPanel = memo(function MotionPanel({ element, motion, onClearSelection, onSetMotion, onClearMotion, }: MotionPanelProps) { const activeMotionPreset = inferMotionPreset(motion); const activeMotionDirection = inferMotionDirection(motion); const activeMotionStart = motion?.start ?? 0; const activeMotionDuration = motion?.duration ?? 0.6; const activeMotionDistance = motionValueDistance(motion); const activeMotionEase = motion?.ease ?? "power3.out"; const customEaseData = motion?.customEase?.data ?? ""; const customEaseActive = customEaseData.trim().length > 0; const activeCustomEasePoints = useMemo( () => parseStudioCustomEaseData(customEaseData) ?? controlPointsForGsapEase( STUDIO_GSAP_EASE_OPTIONS.includes( activeMotionEase as (typeof STUDIO_GSAP_EASE_OPTIONS)[number], ) ? activeMotionEase : "power3.out", ), [activeMotionEase, customEaseData], ); if (!element) { return (

Select an element for motion.

Timeline layers and inspector selections can receive Studio-authored GSAP motion.

); } const sourceLabel = element.id ? `#${element.id}` : element.selector; const easeSelectValue = customEaseActive ? "CustomEase" : STUDIO_GSAP_EASE_OPTIONS.includes( activeMotionEase as (typeof STUDIO_GSAP_EASE_OPTIONS)[number], ) ? activeMotionEase : "power3.out"; const easeSelectOptions = customEaseActive ? ["CustomEase", ...STUDIO_GSAP_EASE_OPTIONS] : STUDIO_GSAP_EASE_OPTIONS; const commitMotion = ( overrides: Partial<{ preset: StudioGsapMotionPreset; direction: StudioGsapMotionDirection; start: number; duration: number; distance: number; ease: string; customEaseData: string; }>, ) => { const customEaseText = overrides.customEaseData ?? customEaseData; const customEase = customEaseText.trim() ? { id: motion?.customEase?.id ?? buildStudioCustomEaseId(element), data: customEaseText.trim(), } : undefined; const nextEase = customEase ? customEase.id : (overrides.ease ?? activeMotionEase).trim() || "none"; onSetMotion( element, buildStudioGsapPresetMotion(overrides.preset ?? activeMotionPreset, { start: clampMotionNumber(overrides.start ?? activeMotionStart, 0, 3600, 0), duration: clampMotionNumber(overrides.duration ?? activeMotionDuration, 0.01, 3600, 0.6), distance: clampMotionNumber(overrides.distance ?? activeMotionDistance, 1, 2000, 32), direction: overrides.direction ?? activeMotionDirection, ease: nextEase, customEase, }), ); }; const commitCustomEase = (points: StudioCustomEaseControlPoints) => { commitMotion({ ease: buildStudioCustomEaseId(element), customEaseData: serializeStudioCustomEaseData(points), }); }; return (
Motion Target
{element.label}
{sourceLabel}
GSAP
} >
commitMotion({ preset: next as StudioGsapMotionPreset })} options={MOTION_PRESET_OPTIONS} />
commitMotion({ direction: next as StudioGsapMotionDirection })} options={MOTION_DIRECTION_OPTIONS} /> { if (next === "CustomEase") return; commitMotion({ ease: next, customEaseData: "" }); }} options={easeSelectOptions} />
commitMotion({ start: parsePlainNumber(next) ?? 0 })} /> commitMotion({ duration: parsePlainNumber(next) ?? 0.6 })} /> commitMotion({ distance: parsePlainNumber(next) ?? 32 })} />
CustomEase
} >
{ const parsed = parseStudioCustomEaseData(next); if (parsed) commitCustomEase(parsed); }} />
); });