From 2d1b905a56c55286c3ab76b3271c277517cec496 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 16:53:29 +0200 Subject: [PATCH] feat(studio): consolidate timeline editor callbacks --- packages/studio/src/App.tsx | 1 + .../src/components/editor/AnimationCard.tsx | 26 +- .../editor/GsapAddAnimationControl.tsx | 68 ++++ .../editor/GsapAnimationSection.tsx | 175 ++-------- .../components/editor/PropertyPanelFlat.tsx | 67 ++-- .../editor/gsapAnimationCallbacks.test.ts | 122 +++++++ .../editor/gsapAnimationCallbacks.ts | 102 +++++- .../editor/propertyPanelFlatMotionSection.tsx | 164 ++-------- .../nle/useTimelineEditCallbacks.test.ts | 30 ++ .../nle/useTimelineEditCallbacks.test.tsx | 306 ++++++++++++++++++ .../nle/useTimelineEditCallbacks.ts | 129 +++++++- .../src/contexts/TimelineEditContext.tsx | 1 + .../src/hooks/useStudioContextValue.test.ts | 93 ++++++ .../studio/src/hooks/useStudioContextValue.ts | 17 +- 14 files changed, 959 insertions(+), 342 deletions(-) create mode 100644 packages/studio/src/components/editor/GsapAddAnimationControl.tsx create mode 100644 packages/studio/src/components/editor/gsapAnimationCallbacks.test.ts create mode 100644 packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx create mode 100644 packages/studio/src/hooks/useStudioContextValue.test.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index eff75a40e..b661872d6 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -401,6 +401,7 @@ export function StudioApp() { panelLayout.rightInspectorPanes, panelLayout.rightCollapsed, isPlaying, + domEditSession.domEditSelection, gestureState === "recording", ); useStudioUrlState({ diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 214d5b013..0f071d301 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 33f6201b2..1fac3972e 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 ef1393a60..86900c126 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, @@ -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([]); const justToggledTimeoutRef = useRef | null>(null); const panelBodyRef = useRef(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) => ( + + toggleOpen(group.id)} + summary={group.summary} + animateEntrance={justToggledIds.includes(group.id)} + /> + + ); return ( @@ -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) => ( - - toggleOpen(g.id)} - summary={g.summary} - animateEntrance={justToggledIds.includes(g.id)} - /> - - ))} + {beforeOpen.map(renderClosedGroup)} {openGroup && (
@@ -548,17 +579,7 @@ export function PropertyPanelFlat({
)} - {afterOpen.map((g) => ( - - toggleOpen(g.id)} - summary={g.summary} - animateEntrance={justToggledIds.includes(g.id)} - /> - - ))} + {afterOpen.map(renderClosedGroup)}
(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", + ]); + }); +}); diff --git a/packages/studio/src/components/editor/gsapAnimationCallbacks.ts b/packages/studio/src/components/editor/gsapAnimationCallbacks.ts index c07d9c83b..d35cf4479 100644 --- a/packages/studio/src/components/editor/gsapAnimationCallbacks.ts +++ b/packages/studio/src/components/editor/gsapAnimationCallbacks.ts @@ -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 = { @@ -51,13 +63,95 @@ const ANIMATION_META_LABELS: Record = * 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, -): 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); 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, + }; +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx index 7458ad508..c8edf195b 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) => ( - - ))} - -
- ) : ( - - )} -
+
)} diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.test.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.test.ts index 662234daa..361cfd615 100644 --- a/packages/studio/src/components/nle/useTimelineEditCallbacks.test.ts +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.test.ts @@ -40,6 +40,36 @@ describe("resolveTimelineKeyframeTarget", () => { ).toBeNull(); }); + it("uses the rendered animation identity to resolve a same-group collision", () => { + expect( + resolveTimelineKeyframeTarget( + 50, + [ + { + percentage: 50, + tweenPercentage: 25, + propertyGroup: "position", + animationId: "position-b", + }, + ], + [ + { id: "position-a", propertyGroup: "position" }, + { id: "position-b", propertyGroup: "position" }, + ], + ), + ).toEqual({ animId: "position-b", tweenPct: 25 }); + }); + + it("rejects a rendered animation identity absent from the element", () => { + expect( + resolveTimelineKeyframeTarget( + 50, + [{ percentage: 50, animationId: "stale-position" }], + [{ id: "position", propertyGroup: "position" }], + ), + ).toBeNull(); + }); + it("keeps a keyframed and flat tween in the same property group unresolved", () => { expect( resolveTimelineKeyframeTarget( diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx b/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx new file mode 100644 index 000000000..09ab781c9 --- /dev/null +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx @@ -0,0 +1,306 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TimelineElement } from "../../player"; +import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks"; +import { usePlayerStore } from "../../player/store/playerStore"; +import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness"; + +installReactActEnvironment(); + +const mocks = vi.hoisted(() => ({ + actions: { + handleGsapRemoveKeyframe: vi.fn(), + handleGsapMoveKeyframeToPlayhead: vi.fn(), + handleGsapMoveKeyframe: vi.fn(), + handleGsapResizeKeyframedTween: vi.fn(), + handleGsapUpdateMeta: vi.fn(), + handleGsapAddKeyframe: vi.fn(), + handleGsapAddKeyframeBatch: vi.fn().mockResolvedValue(undefined), + handleGsapConvertToKeyframes: vi.fn(), + handleGsapRemoveAllKeyframes: vi.fn(), + handleGsapDeleteAnimation: vi.fn(), + buildDomSelectionForTimelineElement: vi.fn(), + }, + selection: { id: "box", selector: "#box", sourceFile: "index.html" }, + animations: Array(), +})); + +vi.mock("../../contexts/StudioContext", () => ({ + useStudioShellContext: () => ({ projectId: "project", activeCompPath: "index.html" }), +})); + +vi.mock("../../contexts/DomEditContext", () => ({ + useDomEditActionsContext: () => mocks.actions, + useDomEditSelectionContext: () => ({ + domEditSelection: mocks.selection, + selectedGsapAnimations: mocks.animations, + }), +})); + +import { useTimelineEditCallbacks } from "./useTimelineEditCallbacks"; + +const element: TimelineElement = { + id: "box", + key: "index.html#box", + domId: "box", + tag: "div", + start: 0, + duration: 1, + track: 0, + sourceFile: "index.html", +}; + +const flatAnimation: GsapAnimation = { + id: "box-to-0-position", + targetSelector: "#box", + method: "to", + position: 0, + resolvedStart: 0, + duration: 1, + properties: { x: 420 }, + propertyGroup: "position", +}; + +const otherFlatAnimation: GsapAnimation = { + ...flatAnimation, + id: "circle-to-0-position", + targetSelector: "#circle", +}; + +const otherKeyframedAnimation: GsapAnimation = { + ...otherFlatAnimation, + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 100, properties: { x: 420 } }, + ], + }, +}; + +function authoredInteriorAnimation(): GsapAnimation { + return { + ...flatAnimation, + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 210 } }, + { percentage: 100, properties: { x: 420 } }, + ], + }, + }; +} + +function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => void } { + let callbacks: TimelineEditCallbacks | null = null; + function Harness() { + callbacks = useTimelineEditCallbacks({ + handleTimelineElementMove: vi.fn(), + handleTimelineElementsMove: vi.fn(), + handleTimelineElementResize: vi.fn(), + handleTimelineGroupResize: vi.fn(), + handleToggleTrackHidden: vi.fn(), + handleBlockedTimelineEdit: vi.fn(), + handleTimelineElementSplit: vi.fn(), + handleRazorSplit: vi.fn(), + handleRazorSplitAll: vi.fn(), + }); + return null; + } + const root = mountReactHarness(); + if (!callbacks) throw new Error("timeline callbacks did not initialize"); + return { callbacks, unmount: () => act(() => root.unmount()) }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.animations = [flatAnimation]; + mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(mocks.selection); + usePlayerStore.setState({ + currentTime: 0.5, + elements: [element], + domClipChildren: [], + keyframeCache: new Map(), + gsapAnimations: new Map([["box", [flatAnimation]]]), + }); +}); + +afterEach(() => { + usePlayerStore.setState({ + elements: [], + domClipChildren: [], + keyframeCache: new Map(), + gsapAnimations: new Map(), + }); +}); + +describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => { + it("adds an interior point through the add-keyframe persist boundary", async () => { + const view = renderCallbacks(); + + await act(async () => { + await view.callbacks.onTogglePropertyGroupKeyframe?.(element, { + animationId: flatAnimation.id, + propertyGroup: "position", + tweenPercentage: 50, + properties: { x: 210 }, + remove: false, + }); + }); + + expect(mocks.actions.handleGsapAddKeyframeBatch).toHaveBeenCalledWith( + flatAnimation.id, + 50, + { x: 210 }, + undefined, + mocks.selection, + ); + expect(mocks.actions.handleGsapConvertToKeyframes).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("safely no-ops a boundary drag while the tween is still flat", () => { + const view = renderCallbacks(); + + act(() => { + view.callbacks.onMoveKeyframe?.("box", 0, 25, "position", 0, flatAnimation.id); + }); + + expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled(); + expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("deletes a non-selected element flat boundary through the clicked element's selection", async () => { + const circle: TimelineElement = { + ...element, + id: "circle", + key: "scenes/main.html#circle", + domId: "circle", + }; + usePlayerStore.setState({ + elements: [element, circle], + gsapAnimations: new Map([["scenes/main.html#circle", [otherFlatAnimation]]]), + }); + const view = renderCallbacks(); + + await act(async () => { + view.callbacks.onDeleteKeyframe?.( + "scenes/main.html#circle", + 0, + "position", + 0, + otherFlatAnimation.id, + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + // Persisted through the CLICKED element's own selection, not the current one. + expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith( + otherFlatAnimation.id, + mocks.selection, + ); + expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("removes a non-selected element authored endpoint through the clicked element's selection", async () => { + const circle: TimelineElement = { + ...element, + id: "circle", + key: "scenes/main.html#circle", + domId: "circle", + }; + usePlayerStore.setState({ + elements: [element, circle], + gsapAnimations: new Map([["index.html#circle", [otherKeyframedAnimation]]]), + }); + const view = renderCallbacks(); + + await act(async () => { + view.callbacks.onDeleteKeyframe?.( + "scenes/main.html#circle", + 100, + "position", + 100, + otherKeyframedAnimation.id, + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith( + otherKeyframedAnimation.id, + 100, + undefined, + mocks.selection, + ); + expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("keeps selected-element flat boundary deletion on the animation delete path", () => { + const view = renderCallbacks(); + + act(() => { + view.callbacks.onDeleteKeyframe?.("box", 0, "position", 0, flatAnimation.id); + }); + + expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith(flatAnimation.id); + expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("routes the flat lane-header remove toggle through the guarded delete path", async () => { + const view = renderCallbacks(); + + await act(async () => { + await view.callbacks.onTogglePropertyGroupKeyframe?.(element, { + animationId: flatAnimation.id, + propertyGroup: "position", + tweenPercentage: 100, + properties: { x: 420 }, + remove: true, + }); + }); + + expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith( + flatAnimation.id, + mocks.selection, + ); + expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("keeps authored interior deletion on the per-keyframe path", () => { + mocks.animations = [authoredInteriorAnimation()]; + usePlayerStore.setState({ gsapAnimations: new Map([["box", mocks.animations]]) }); + const view = renderCallbacks(); + + act(() => { + view.callbacks.onDeleteKeyframe?.("box", 50, "position", 50, flatAnimation.id); + }); + + expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(flatAnimation.id, 50); + expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("keeps an authored interior drag on the per-keyframe move path", () => { + mocks.animations = [authoredInteriorAnimation()]; + const view = renderCallbacks(); + + act(() => { + view.callbacks.onMoveKeyframe?.("box", 50, 75, "position", 50, flatAnimation.id); + }); + + expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(flatAnimation.id, 50, 75); + expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled(); + view.unmount(); + }); +}); diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts index a1fa65852..c800528ae 100644 --- a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { TimelineElement } from "../../player"; import { usePlayerStore } from "../../player/store/playerStore"; import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; @@ -11,6 +12,7 @@ import { import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler"; import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache"; import { resolveKeyframeRetime } from "../editor/keyframeRetime"; +import type { DomEditSelection } from "../editor/domEditingTypes"; import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter"; export interface TimelineEditCallbackDeps { @@ -46,15 +48,14 @@ interface TimelineCachedKeyframe { percentage: number; tweenPercentage?: number; propertyGroup?: string; + animationId?: string; } /** * Resolve a rendered timeline diamond back to the animation that authored it. - * Flat tweens use synthesized diamonds, so a mixed flat tween may have neither - * a property group nor real keyframes. The cache currently carries a property - * group, not an animation id, so resolution is safe only when that group has a - * single candidate. Ambiguous candidates remain unresolved rather than - * retiming an arbitrary tween. + * Prefer the animation identity carried by the rendered keyframe. Legacy cache + * entries without one are safe only when their property group has one candidate; + * ambiguous candidates remain unresolved rather than retiming an arbitrary tween. */ export function resolveTimelineKeyframeTarget( pct: number, @@ -63,6 +64,14 @@ export function resolveTimelineKeyframeTarget( ): { animId: string; tweenPct: number } | null { const kf = keyframes.find((item) => Math.abs(item.percentage - pct) < 0.2); if (!kf) return null; + const identifiedAnimation = kf.animationId + ? animations.find((animation) => animation.id === kf.animationId) + : undefined; + if (kf.animationId) { + return identifiedAnimation + ? { animId: identifiedAnimation.id, tweenPct: kf.tweenPercentage ?? pct } + : null; + } const group = kf?.propertyGroup; const candidates = group ? animations.filter((animation) => animation.propertyGroup === group) @@ -98,24 +107,76 @@ export function useTimelineEditCallbacks({ handleGsapResizeKeyframedTween, handleGsapUpdateMeta, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, + handleGsapDeleteAnimation, buildDomSelectionForTimelineElement, } = useDomEditActionsContext(); + const resolveElementAnimations = useCallback( + (elementKey: string): GsapAnimation[] => { + const { gsapAnimations } = usePlayerStore.getState(); + const hashIndex = elementKey.lastIndexOf("#"); + const elementId = hashIndex === -1 ? elementKey : elementKey.slice(hashIndex + 1); + const sourceFile = + hashIndex === -1 ? (activeCompPath ?? "index.html") : elementKey.slice(0, hashIndex); + return ( + gsapAnimations.get(`${sourceFile}#${elementId}`) ?? + gsapAnimations.get(`index.html#${elementId}`) ?? + gsapAnimations.get(elementId) ?? + [] + ); + }, + [activeCompPath], + ); + // Resolve a timeline-diamond callback's clip-% to the keyframe's anim id + its // tween-relative percentage (shared by the delete/move keyframe callbacks): the // diamond reports a clip-% but the script ops key on the tween-%. Prefers the // anim in the keyframe's property group, falling back to the first keyframed one. const resolveKeyframeTarget = useCallback( // fallow-ignore-next-line complexity - (pct: number): { animId: string; tweenPct: number } | null => { + ( + pct: number, + propertyGroup?: string, + tweenPercentage?: number, + animationId?: string, + animations: GsapAnimation[] = selectedGsapAnimations, + ): { animId: string; tweenPct: number } | null => { + const explicitTarget = + propertyGroup !== undefined || tweenPercentage !== undefined || animationId !== undefined + ? [{ percentage: pct, propertyGroup, tweenPercentage, animationId }] + : undefined; const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? ""); - return resolveTimelineKeyframeTarget(pct, cached?.keyframes ?? [], selectedGsapAnimations); + return resolveTimelineKeyframeTarget( + pct, + explicitTarget ?? cached?.keyframes ?? [], + animations, + ); }, [domEditSelection?.id, selectedGsapAnimations], ); + const removeKeyframeTarget = useCallback( + ( + animationId: string, + percentage: number, + animations: GsapAnimation[], + selectionOverride?: DomEditSelection | null, + ) => { + const animation = animations.find((candidate) => candidate.id === animationId); + if (animation && !animation.keyframes) { + if (selectionOverride === undefined) handleGsapDeleteAnimation(animationId); + else handleGsapDeleteAnimation(animationId, selectionOverride); + return; + } + if (selectionOverride === undefined) handleGsapRemoveKeyframe(animationId, percentage); + else handleGsapRemoveKeyframe(animationId, percentage, undefined, selectionOverride); + }, + [handleGsapDeleteAnimation, handleGsapRemoveKeyframe], + ); + return useMemo( () => ({ onMoveElement: handleTimelineElementMove, @@ -135,13 +196,25 @@ export function useTimelineEditCallbacks({ if (!anim) return; handleGsapRemoveAllKeyframes(anim.id); }, - onDeleteKeyframe: (_elId: string, pct: number) => { - const target = resolveKeyframeTarget(pct); - if (target) handleGsapRemoveKeyframe(target.animId, target.tweenPct); + onDeleteKeyframe: (elId, pct, group, tweenPct, animationId) => { + const animations = resolveElementAnimations(elId); + const target = resolveKeyframeTarget(pct, group, tweenPct, animationId, animations); + if (!target) return; + const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId); + if (!element) { + removeKeyframeTarget(target.animId, target.tweenPct, animations); + return; + } + // Persist through the CLICKED element's own selection so a deletion on a + // non-selected element (especially one in a different source file) commits + // against the right element instead of the current domEditSelection. + void buildDomSelectionForTimelineElement(element).then((selection) => { + removeKeyframeTarget(target.animId, target.tweenPct, animations, selection); + }); }, // Retime the keyframe to the playhead, preserving its value + ease. - onMoveKeyframeToPlayhead: (_elId: string, pct: number) => { - const target = resolveKeyframeTarget(pct); + onMoveKeyframeToPlayhead: (_elId, pct, group, tweenPct, animationId) => { + const target = resolveKeyframeTarget(pct, group, tweenPct, animationId); if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct); }, // Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives @@ -152,13 +225,17 @@ export function useTimelineEditCallbacks({ // resizes the tween — position/duration grow so the dragged keyframe lands at // the drop while every other keyframe keeps its absolute time (value+ease too). // fallow-ignore-next-line complexity - onMoveKeyframe: async (_elId: string, fromClipPct: number, toClipPct: number) => { - const target = resolveKeyframeTarget(fromClipPct); + onMoveKeyframe: async (_elId, fromClipPct, toClipPct, group, tweenPct, animationId) => { + const target = resolveKeyframeTarget(fromClipPct, group, tweenPct, animationId); const sel = domEditSelection; if (!target || !sel) return false; const anim = selectedGsapAnimations.find((a) => a.id === target.animId); const tweenStart = anim ? resolveTweenStart(anim) : null; if (!anim || tweenStart === null) return false; + // Synthesized flat endpoints are clip boundaries, not authored keyframes. + // Boundary-to-clip resize wiring is intentionally deferred; ignore the + // drag rather than dispatching a free keyframe move that cannot be written. + if (!anim.keyframes) return false; const tweenDuration = anim.duration ?? resolveTweenDuration(anim); const sourceFile = sel.sourceFile || activeCompPath || "index.html"; const { elements, domClipChildren } = usePlayerStore.getState(); @@ -230,6 +307,26 @@ export function useTimelineEditCallbacks({ if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id); } }, + onTogglePropertyGroupKeyframe: async (element, target) => { + const selection = await buildDomSelectionForTimelineElement(element); + if (!selection) return; + if (target.remove) { + removeKeyframeTarget( + target.animationId, + target.tweenPercentage, + selectedGsapAnimations, + selection, + ); + return; + } + await handleGsapAddKeyframeBatch( + target.animationId, + target.tweenPercentage, + target.properties, + undefined, + selection, + ); + }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -243,14 +340,16 @@ export function useTimelineEditCallbacks({ handleRazorSplit, handleRazorSplitAll, handleGsapRemoveAllKeyframes, + resolveElementAnimations, resolveKeyframeTarget, + removeKeyframeTarget, selectedGsapAnimations, - handleGsapRemoveKeyframe, handleGsapMoveKeyframeToPlayhead, handleGsapMoveKeyframe, handleGsapResizeKeyframedTween, handleGsapUpdateMeta, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapConvertToKeyframes, buildDomSelectionForTimelineElement, projectId, diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index c2b6edc6c..be7f4e9ad 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -44,6 +44,7 @@ export function TimelineEditProvider({ value.onMoveKeyframeToPlayhead, value.onMoveKeyframe, value.onToggleKeyframeAtPlayhead, + value.onTogglePropertyGroupKeyframe, ], ); return {children}; diff --git a/packages/studio/src/hooks/useStudioContextValue.test.ts b/packages/studio/src/hooks/useStudioContextValue.test.ts new file mode 100644 index 000000000..01228d92e --- /dev/null +++ b/packages/studio/src/hooks/useStudioContextValue.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment happy-dom + +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { DomEditSelection } from "../components/editor/domEditing"; +import type { RightInspectorPanes } from "../utils/studioHelpers"; +import { makeSelection } from "./domSelectionTestHarness"; +import { useInspectorState, type InspectorState } from "./useStudioContextValue"; + +interface HarnessProps { + rightPanelTab: string; + rightInspectorPanes: RightInspectorPanes; + rightCollapsed: boolean; + isPlaying: boolean; + isGestureRecording: boolean; + domEditSelection: DomEditSelection | null; +} + +function renderInspectorState(props: HarnessProps): InspectorState { + let state: InspectorState | null = null; + + function Harness() { + state = useInspectorState( + props.rightPanelTab, + props.rightInspectorPanes, + props.rightCollapsed, + props.isPlaying, + props.domEditSelection, + props.isGestureRecording, + ); + return null; + } + + renderToStaticMarkup(React.createElement(Harness)); + if (!state) throw new Error("Expected inspector state"); + return state; +} + +function selectedProps( + overrides: Partial = {}, +): HarnessProps & { domEditSelection: DomEditSelection } { + const element = document.createElement("div"); + return { + rightPanelTab: "renders", + rightInspectorPanes: { layers: false, design: false }, + rightCollapsed: true, + isPlaying: false, + isGestureRecording: false, + domEditSelection: makeSelection("Selected", element), + ...overrides, + }; +} + +describe("useInspectorState", () => { + it("shows the motion path for pure selection with the inspector collapsed", () => { + expect(renderInspectorState(selectedProps()).shouldShowMotionPath).toBe(true); + }); + + it("hides the motion path without a selection", () => { + expect( + renderInspectorState({ ...selectedProps(), domEditSelection: null }).shouldShowMotionPath, + ).toBe(false); + }); + + it("hides the motion path during playback", () => { + expect(renderInspectorState(selectedProps({ isPlaying: true })).shouldShowMotionPath).toBe( + false, + ); + }); + + it("hides the motion path during gesture recording", () => { + expect( + renderInspectorState(selectedProps({ isGestureRecording: true })).shouldShowMotionPath, + ).toBe(false); + }); + + it("keeps selected DOM bounds coupled to the inspector or variables panel", () => { + expect(renderInspectorState(selectedProps()).shouldShowSelectedDomBounds).toBe(false); + expect( + renderInspectorState( + selectedProps({ + rightPanelTab: "design", + rightInspectorPanes: { layers: false, design: true }, + }), + ).shouldShowSelectedDomBounds, + ).toBe(true); + expect( + renderInspectorState(selectedProps({ rightPanelTab: "variables" })) + .shouldShowSelectedDomBounds, + ).toBe(true); + }); +}); diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts index 460028bdf..21ba371f7 100644 --- a/packages/studio/src/hooks/useStudioContextValue.ts +++ b/packages/studio/src/hooks/useStudioContextValue.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo, useRef, useState, type DragEvent } from "react"; import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability"; +import type { DomEditSelection } from "../components/editor/domEditing"; import type { StudioContextValue } from "../contexts/StudioContext"; import type { RightInspectorPanes } from "../utils/studioHelpers"; import type { TimelineFileDropHandler } from "./useTimelineEditingTypes"; @@ -69,6 +70,7 @@ export interface InspectorState { designPanelActive: boolean; inspectorPanelActive: boolean; inspectorButtonActive: boolean; + shouldShowMotionPath: boolean; shouldShowSelectedDomBounds: boolean; } @@ -77,6 +79,7 @@ export function useInspectorState( rightInspectorPanes: RightInspectorPanes, rightCollapsed: boolean, isPlaying: boolean, + domEditSelection: DomEditSelection | null, isGestureRecording?: boolean, ): InspectorState { // fallow-ignore-next-line complexity @@ -93,8 +96,9 @@ export function useInspectorState( inspectorPanelActive, inspectorButtonActive: STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive, - // Keep the selection box + motion path drawn even when the Inspector is - // collapsed — closing the panel shouldn't visually deselect the element. + shouldShowMotionPath: !!domEditSelection && !isPlaying && !isGestureRecording, + // Keep the selection box drawn even when the Inspector is collapsed — + // closing the panel shouldn't visually deselect the element. // The Variables tab also works against the canvas selection (bind card), // so the selection outline stays visible there too. shouldShowSelectedDomBounds: @@ -102,7 +106,14 @@ export function useInspectorState( !isPlaying && !isGestureRecording, }; - }, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]); + }, [ + rightPanelTab, + rightInspectorPanes, + rightCollapsed, + isPlaying, + isGestureRecording, + domEditSelection, + ]); } // fallow-ignore-next-line complexity