From 2158ba08a59849924c7d6813e1930ca58643f1ca Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 16 Jul 2026 15:10:44 -0400 Subject: [PATCH] feat(studio): bulk-edit easing for merged keyframes --- .../src/components/StudioRightPanel.tsx | 2 + .../components/editor/AnimationCard.test.tsx | 223 ++++++++++++++---- .../src/components/editor/AnimationCard.tsx | 23 +- .../editor/EaseCurveSection.test.tsx | 33 ++- .../components/editor/EaseCurveSection.tsx | 7 + .../editor/GsapAnimationSection.tsx | 1 + .../components/editor/KeyframeEaseList.tsx | 9 +- .../src/components/editor/PropertyPanel.tsx | 2 + .../components/editor/PropertyPanelFlat.tsx | 3 + .../editor/gsapAnimationCallbacks.ts | 1 + .../components/editor/propertyPanelTypes.ts | 2 + .../studio/src/contexts/DomEditContext.tsx | 4 + .../hooks/gsapKeyframeCacheHelpers.test.ts | 31 +++ .../src/hooks/gsapKeyframeCacheHelpers.ts | 14 +- .../studio/src/hooks/gsapTweenSynth.test.ts | 22 +- packages/studio/src/hooks/gsapTweenSynth.ts | 38 ++- .../src/hooks/useDomEditSession.test.tsx | 137 ++++++++++- .../studio/src/hooks/useDomEditSession.ts | 47 ++-- .../components/TimelineClipDiamonds.test.tsx | 19 +- .../components/TimelineClipDiamonds.tsx | 19 +- .../components/timelineKeyframeIdentity.ts | 1 + .../useTimelineKeyframeHandlers.test.tsx | 32 +++ .../components/useTimelineKeyframeHandlers.ts | 1 + .../studio/src/player/store/keyframeSlice.ts | 19 +- 24 files changed, 557 insertions(+), 133 deletions(-) diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 3d68a15f45..d725ac50cd 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -152,6 +152,7 @@ export function StudioRightPanel({ handleUpdateArcSegment, handleUnroll, handleUpdateKeyframeEase, + handleUpdateSegmentEase, handleSetAllKeyframeEases, handleGsapAddKeyframe, handleGsapRemoveKeyframe, @@ -408,6 +409,7 @@ export function StudioRightPanel({ onUpdateArcSegment={handleUpdateArcSegment} onUnroll={handleUnroll} onUpdateKeyframeEase={handleUpdateKeyframeEase} + onUpdateSegmentEase={handleUpdateSegmentEase} onSetAllKeyframeEases={handleSetAllKeyframeEases} recordingState={recordingState} recordingDuration={recordingDuration} diff --git a/packages/studio/src/components/editor/AnimationCard.test.tsx b/packages/studio/src/components/editor/AnimationCard.test.tsx index 42f93254f7..0f674db410 100644 --- a/packages/studio/src/components/editor/AnimationCard.test.tsx +++ b/packages/studio/src/components/editor/AnimationCard.test.tsx @@ -2,9 +2,9 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { AnimationCard } from "./AnimationCard"; -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { EASE_PRESETS } from "./easePresetLibrary"; const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn()); @@ -12,29 +12,78 @@ vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit })); (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const ANIMATION: GsapAnimation = { + id: "position-tween", + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 2, + ease: "power1.out", + properties: { x: 200 }, + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 100 } }, + { percentage: 100, properties: { x: 200 } }, + ], + }, +}; + +const FLAT_ANIMATION: GsapAnimation = { + ...ANIMATION, + id: "flat-position-tween", + keyframes: undefined, +}; + afterEach(() => { document.body.innerHTML = ""; trackStudioSegmentEaseEdit.mockClear(); }); -function baseAnimation(overrides: Partial = {}): GsapAnimation { - return { - id: "anim-1", - method: "to", - position: 0.8, - duration: 1.2, - ease: "power2.out", - properties: { opacity: 1 }, - ...overrides, - } as GsapAnimation; +function renderCard( + focusedSegment: { tweenPercentage: number; collidingAnimationIds?: string[] } | null, + onEaseCommit = vi.fn(), + defaultExpanded = false, + animation = ANIMATION, + onUpdateMeta = vi.fn(), + onUpdateSegmentEase = vi.fn(), +) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const render = (nextFocusedSegment: { tweenPercentage: number } | null) => { + act(() => { + root.render( + , + ); + }); + }; + render(focusedSegment); + return { host, root, render }; } -const noop = () => {}; +function findButton(host: HTMLElement, text: string): HTMLButtonElement | undefined { + return Array.from(host.querySelectorAll("button")).find((button) => + button.textContent?.includes(text), + ); +} function selectPreset(host: HTMLElement, presetId: string): string { const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId); if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`); - const dropdown = host.querySelector("[data-ease-type-dropdown]"); expect(dropdown).not.toBeNull(); act(() => dropdown?.click()); @@ -45,38 +94,121 @@ function selectPreset(host: HTMLElement, presetId: string): string { return presetConfig.ease; } -function renderExpandedCard({ - animation, - flat, - onUpdateMeta = vi.fn(), - onUpdateKeyframeEase = vi.fn(), -}: { - animation: GsapAnimation; - flat?: boolean; - onUpdateMeta?: ReturnType; - onUpdateKeyframeEase?: ReturnType; -}) { - const host = document.createElement("div"); - document.body.append(host); - const root = createRoot(host); - act(() => { - root.render( - , +function restoreScrollIntoView(descriptor: PropertyDescriptor | undefined): void { + if (descriptor) Object.defineProperty(HTMLElement.prototype, "scrollIntoView", descriptor); + else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); +} + +describe("AnimationCard", () => { + it("scrolls a focused segment into view but not a manually toggled segment", () => { + const originalScrollIntoView = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "scrollIntoView", ); + const scrollIntoView = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: scrollIntoView, + }); + + const view = renderCard({ tweenPercentage: 50 }); + try { + expect(scrollIntoView).toHaveBeenCalledOnce(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + view.render(null); + const manualToggle = findButton(view.host, "50% → 100%"); + expect(manualToggle).toBeDefined(); + act(() => manualToggle?.click()); + expect(scrollIntoView).toHaveBeenCalledOnce(); + } finally { + act(() => view.root.unmount()); + restoreScrollIntoView(originalScrollIntoView); + } }); - return { host, root }; + + it("tracks a committed segment ease alongside the existing update", () => { + const onEaseCommit = vi.fn(); + const view = renderCard(null, onEaseCommit, true); + const segment = findButton(view.host, "0% → 50%"); + expect(segment).toBeDefined(); + act(() => segment?.click()); + const ease = selectPreset(view.host, "quad-out"); + + expect(onEaseCommit).toHaveBeenCalledWith(ANIMATION.id, 50, ease); + expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "commit", ease }); + act(() => view.root.unmount()); + }); + + it("commits a focused multi-id segment ease through the bulk callback", () => { + const onUpdateKeyframeEase = vi.fn(); + const onUpdateSegmentEase = vi.fn(); + const collidingAnimationIds = [ANIMATION.id, "scale-tween", "opacity-tween"]; + const view = renderCard( + { tweenPercentage: 50, collidingAnimationIds }, + onUpdateKeyframeEase, + false, + ANIMATION, + vi.fn(), + onUpdateSegmentEase, + ); + const ease = selectPreset(view.host, "quad-out"); + + expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(collidingAnimationIds, 50, ease); + expect(onUpdateKeyframeEase).not.toHaveBeenCalled(); + act(() => view.root.unmount()); + }); + + it("keeps a focused single-id segment ease on the single callback", () => { + const onUpdateKeyframeEase = vi.fn(); + const onUpdateSegmentEase = vi.fn(); + const view = renderCard( + { tweenPercentage: 50, collidingAnimationIds: [ANIMATION.id] }, + onUpdateKeyframeEase, + false, + ANIMATION, + vi.fn(), + onUpdateSegmentEase, + ); + const ease = selectPreset(view.host, "quad-out"); + + expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(ANIMATION.id, 50, ease); + expect(onUpdateSegmentEase).not.toHaveBeenCalled(); + act(() => view.root.unmount()); + }); + + it("commits a focused flat tween segment ease through tween metadata", () => { + const onUpdateMeta = vi.fn(); + const onUpdateKeyframeEase = vi.fn(); + const view = renderCard( + { tweenPercentage: 100 }, + onUpdateKeyframeEase, + false, + FLAT_ANIMATION, + onUpdateMeta, + ); + const ease = selectPreset(view.host, "quad-out"); + + expect(onUpdateMeta).toHaveBeenCalledExactlyOnceWith(FLAT_ANIMATION.id, { ease }); + expect(onUpdateKeyframeEase).not.toHaveBeenCalled(); + act(() => view.root.unmount()); + }); +}); + +function baseAnimation(overrides: Partial = {}): GsapAnimation { + return { + id: "anim-1", + method: "to", + position: 0.8, + duration: 1.2, + ease: "power2.out", + properties: { opacity: 1 }, + ...overrides, + } as GsapAnimation; } +const noop = () => {}; + describe("AnimationCard ease editing", () => { it("commits one preset change to the selected keyframe segment", () => { const onUpdateKeyframeEase = vi.fn(); @@ -90,7 +222,7 @@ describe("AnimationCard ease editing", () => { ], }, }); - const view = renderExpandedCard({ animation, onUpdateKeyframeEase }); + const view = renderCard(null, onUpdateKeyframeEase, true, animation); const segment = Array.from(view.host.querySelectorAll("button")).find((button) => button.textContent?.includes("0% → 50%"), @@ -111,12 +243,7 @@ describe("AnimationCard ease editing", () => { const onUpdateMeta = vi.fn(); const onUpdateKeyframeEase = vi.fn(); const animation = baseAnimation({ id: "flat-tween" }); - const view = renderExpandedCard({ - animation, - flat: true, - onUpdateMeta, - onUpdateKeyframeEase, - }); + const view = renderCard(null, onUpdateKeyframeEase, true, animation, onUpdateMeta); const ease = selectPreset(view.host, "quad-out"); diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 0f071d3016..5529aa65ed 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -23,7 +23,7 @@ interface AnimationCardProps extends GsapAnimationEditCallbacks { animation: GsapAnimation; defaultExpanded: boolean; flat?: boolean; - focusedSegment?: { tweenPercentage: number } | null; + focusedSegment?: { tweenPercentage: number; collidingAnimationIds?: string[] } | null; onFocusSegmentConsumed?: () => void; } @@ -47,6 +47,7 @@ export const AnimationCard = memo(function AnimationCard({ onSetArcPath, onUpdateArcSegment, onUpdateKeyframeEase, + onUpdateSegmentEase, onSetAllKeyframeEases, onUnroll, }: AnimationCardProps) { @@ -54,6 +55,9 @@ export const AnimationCard = memo(function AnimationCard({ const [addingProp, setAddingProp] = useState(false); const [addingFromProp, setAddingFromProp] = useState(false); const [expandedKfPct, setExpandedKfPct] = useState(null); + const [focusedCollidingAnimationIds, setFocusedCollidingAnimationIds] = useState< + string[] | undefined + >(); const cardRef = useRef(null); const pendingAutoScrollRef = useRef(false); @@ -62,6 +66,7 @@ export const AnimationCard = memo(function AnimationCard({ setExpanded(true); pendingAutoScrollRef.current = true; setExpandedKfPct(focusedSegment.tweenPercentage); + setFocusedCollidingAnimationIds(focusedSegment.collidingAnimationIds); onFocusSegmentConsumed?.(); }, [focusedSegment, onFocusSegmentConsumed]); @@ -288,9 +293,21 @@ export const AnimationCard = memo(function AnimationCard({ keyframes={animation.keyframes.keyframes} globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"} expandedPct={expandedKfPct} - onToggle={setExpandedKfPct} + collidingAnimationIds={focusedCollidingAnimationIds} + onToggle={(pct) => { + setExpandedKfPct(pct); + setFocusedCollidingAnimationIds(undefined); + }} onEaseCommit={(pct, ease) => { - onUpdateKeyframeEase(animation.id, pct, ease); + if ( + focusedCollidingAnimationIds && + focusedCollidingAnimationIds.length > 1 && + onUpdateSegmentEase + ) { + onUpdateSegmentEase(focusedCollidingAnimationIds, pct, ease); + } else { + onUpdateKeyframeEase(animation.id, pct, ease); + } trackStudioSegmentEaseEdit({ action: "commit", ease }); }} onApplyAll={ diff --git a/packages/studio/src/components/editor/EaseCurveSection.test.tsx b/packages/studio/src/components/editor/EaseCurveSection.test.tsx index d72e9e85ac..5e3bb46335 100644 --- a/packages/studio/src/components/editor/EaseCurveSection.test.tsx +++ b/packages/studio/src/components/editor/EaseCurveSection.test.tsx @@ -11,12 +11,22 @@ afterEach(() => { document.body.innerHTML = ""; }); -function renderSection(ease = "none", onCustomEaseCommit = vi.fn()) { +function renderSection( + ease = "none", + onCustomEaseCommit = vi.fn(), + collidingAnimationIds?: string[], +) { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { - root.render(); + root.render( + , + ); }); return { host, root, onCustomEaseCommit }; } @@ -82,6 +92,25 @@ function editorLabel(host: HTMLElement): string | null { } describe("EaseCurveSection preset grid", () => { + it("shows the number of properties for a multi-id segment", () => { + const { host, root } = renderSection("power2.out", vi.fn(), ["move-x", "move-y", "fade"]); + + expect(host.textContent).toContain("Applies to 3 properties"); + + act(() => root.unmount()); + }); + + it.each([undefined, ["move-x"]])( + "does not show a property count for a non-colliding segment", + (collidingAnimationIds) => { + const { host, root } = renderSection("power2.out", vi.fn(), collidingAnimationIds); + + expect(host.textContent).not.toContain("Applies to"); + + act(() => root.unmount()); + }, + ); + it.each([ ["curve", "none", "linear", ["flow-7", "spring-bouncy"]], ["spring", "spring(0.42)", "spring-bouncy", ["linear", "flow-7"]], diff --git a/packages/studio/src/components/editor/EaseCurveSection.tsx b/packages/studio/src/components/editor/EaseCurveSection.tsx index f823d4fe40..470e71f5a1 100644 --- a/packages/studio/src/components/editor/EaseCurveSection.tsx +++ b/packages/studio/src/components/editor/EaseCurveSection.tsx @@ -235,9 +235,11 @@ function EaseParameterField({ export function EaseCurveSection({ ease, onCustomEaseCommit, + collidingAnimationIds, }: { ease: string; onCustomEaseCommit: (ease: string) => void; + collidingAnimationIds?: string[]; }) { const springBounce = parseSpringBounce(ease); const isSpring = springBounce !== null; @@ -320,6 +322,11 @@ export function EaseCurveSection({ return (
+ {collidingAnimationIds && collidingAnimationIds.length > 1 && ( +

+ Applies to {collidingAnimationIds.length} properties +

+ )} {showGraph ? ( <> diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index 1fac3972e9..f0ea32be71 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -58,6 +58,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ focusedEaseSegment?.animationId === anim.id ? focusedEaseSegment : null } onFocusSegmentConsumed={() => setFocusedEaseSegment(null)} + onUpdateSegmentEase={callbacks.onUpdateSegmentEase} /> ))} diff --git a/packages/studio/src/components/editor/KeyframeEaseList.tsx b/packages/studio/src/components/editor/KeyframeEaseList.tsx index 4574433d7b..a9fe061b58 100644 --- a/packages/studio/src/components/editor/KeyframeEaseList.tsx +++ b/packages/studio/src/components/editor/KeyframeEaseList.tsx @@ -44,6 +44,7 @@ export function KeyframeEaseList({ keyframes, globalEase, expandedPct, + collidingAnimationIds, onToggle, onEaseCommit, onApplyAll, @@ -51,6 +52,7 @@ export function KeyframeEaseList({ keyframes: GsapPercentageKeyframe[]; globalEase: string; expandedPct: number | null; + collidingAnimationIds?: string[]; onToggle: (pct: number | null) => void; onEaseCommit: (pct: number, ease: string) => void; /** Apply one ease to every segment at once (clears per-segment overrides). */ @@ -93,7 +95,11 @@ export function KeyframeEaseList({ ? "Custom" : (EASE_LABELS[segEase] ?? segEase); return ( -
+