diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 214d5b013d..0f071d3016 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -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(null); + const cardRef = useRef(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( + `[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 (
void; + onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; + track: (control: string, name: string) => void; + variant: keyof typeof STYLES; +}) { + const styles = STYLES[variant]; + + return ( +
+ {open ? ( +
+ {ADD_METHODS.map((method) => ( + + ))} + +
+ ) : ( + + )} +
+ ); +} diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index 33f6201b2f..1fac3972e9 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -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 (
}> @@ -76,137 +50,24 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
{animations.map((anim, index) => ( { - 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)} /> ))} -
- {addMenuOpen ? ( -
- {ADD_METHODS.map((method) => ( - - ))} - -
- ) : ( - - )} -
+
)}
diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index 05c12d31fe..e4008f3b3c 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -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, @@ -252,6 +253,23 @@ export function PropertyPanelFlat({ // 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([]); const justToggledTimeoutRef = useRef | null>(null); useEffect(() => { @@ -504,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) => ( + + toggleOpen(group.id)} + summary={group.summary} + animateEntrance={justToggledIds.includes(group.id)} + /> + + ); return ( @@ -527,17 +556,7 @@ export function PropertyPanelFlat({ />
- {beforeOpen.map((g) => ( - - toggleOpen(g.id)} - summary={g.summary} - animateEntrance={justToggledIds.includes(g.id)} - /> - - ))} + {beforeOpen.map(renderClosedGroup)} {openGroup && (
@@ -556,17 +575,7 @@ export function PropertyPanelFlat({
)} - {afterOpen.map((g) => ( - - toggleOpen(g.id)} - summary={g.summary} - animateEntrance={justToggledIds.includes(g.id)} - /> - - ))} + {afterOpen.map(renderClosedGroup)}
= * added later is attributed honestly by its own key instead of poisoning another * control's usage count. */ -export function trackAnimationMetaUpdate( - track: TrackDesignInput, - updates: Record, -): void { +function trackAnimationMetaUpdate(track: TrackDesignInput, updates: Record): void { for (const key of Object.keys(updates)) { const mapped = ANIMATION_META_LABELS[key]; if (mapped) track(mapped.control, mapped.name); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx index 7458ad508a..c8edf195b5 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx @@ -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 (
@@ -172,140 +176,22 @@ export function FlatMotionSection({
{animations.map((anim, index) => ( { - 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)} /> ))} -
- {addMenuOpen ? ( -
- {ADD_METHODS.map((method) => ( - - ))} - -
- ) : ( - - )} -
+
)}