diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 299d462ff8..08004d3ebd 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -19,7 +19,6 @@ import type { EditHistoryKind } from "../utils/editHistory"; import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist"; import { useSlideshowTabState } from "../hooks/useSlideshowTabState"; import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider"; - import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext"; import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; import { useFileManagerContext } from "../contexts/FileManagerContext"; @@ -156,6 +155,7 @@ export function StudioRightPanel({ handleUpdateArcSegment, handleUnroll, handleUpdateKeyframeEase, + handleUpdateSegmentEase, handleSetAllKeyframeEases, handleGsapAddKeyframe, handleGsapRemoveKeyframe, @@ -406,6 +406,7 @@ export function StudioRightPanel({ onUnroll={handleUnroll} onUpdateKeyframeEase={handleUpdateKeyframeEase} onSetAllKeyframeEases={handleSetAllKeyframeEases} + onUpdateSegmentEase={handleUpdateSegmentEase} recordingState={recordingState} recordingDuration={recordingDuration} onToggleRecording={onToggleRecording} diff --git a/packages/studio/src/components/editor/AnimationCard.test.tsx b/packages/studio/src/components/editor/AnimationCard.test.tsx index ea51978c9a..19b455724f 100644 --- a/packages/studio/src/components/editor/AnimationCard.test.tsx +++ b/packages/studio/src/components/editor/AnimationCard.test.tsx @@ -2,34 +2,88 @@ 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"; +import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth"; const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn()); 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 renderFocusCard( + focusedSegment: { + tweenPercentage: number; + collidingAnimationTargets?: AnimationKeyframeTarget[]; + } | 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); @@ -45,6 +99,8 @@ function selectPreset(host: HTMLElement, presetId: string): string { return presetConfig.ease; } +const noop = () => {}; + /** Every test mounts the same card; only expansion, flat mode, and the spies differ. */ function renderCard({ animation = baseAnimation(), @@ -82,6 +138,126 @@ function renderCard({ return { host, root }; } +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 = renderFocusCard({ 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); + } + }); + + it("tracks a committed segment ease alongside the existing update", () => { + const onEaseCommit = vi.fn(); + const view = renderFocusCard(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 collidingAnimationTargets = [ + { animationId: ANIMATION.id, tweenPercentage: 50 }, + { animationId: "scale-tween", tweenPercentage: 75 }, + { animationId: "opacity-tween", tweenPercentage: 25 }, + ]; + const view = renderFocusCard( + { tweenPercentage: 50, collidingAnimationTargets }, + onUpdateKeyframeEase, + false, + ANIMATION, + vi.fn(), + onUpdateSegmentEase, + ); + const ease = selectPreset(view.host, "quad-out"); + + expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(collidingAnimationTargets, 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 = renderFocusCard( + { + tweenPercentage: 50, + collidingAnimationTargets: [{ animationId: ANIMATION.id, tweenPercentage: 50 }], + }, + 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 = renderFocusCard( + { 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; +} describe("AnimationCard ease editing", () => { it("commits one preset change to the selected keyframe segment", () => { const onUpdateKeyframeEase = vi.fn(); diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 0f071d3016..97e4a4f5b7 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -18,12 +18,16 @@ import { parseNumericOrString, BOOLEAN_PROPS, } from "./AnimationCardParts"; +import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth"; interface AnimationCardProps extends GsapAnimationEditCallbacks { animation: GsapAnimation; defaultExpanded: boolean; flat?: boolean; - focusedSegment?: { tweenPercentage: number } | null; + focusedSegment?: { + tweenPercentage: number; + collidingAnimationTargets?: AnimationKeyframeTarget[]; + } | null; onFocusSegmentConsumed?: () => void; } @@ -47,6 +51,7 @@ export const AnimationCard = memo(function AnimationCard({ onSetArcPath, onUpdateArcSegment, onUpdateKeyframeEase, + onUpdateSegmentEase, onSetAllKeyframeEases, onUnroll, }: AnimationCardProps) { @@ -54,6 +59,9 @@ export const AnimationCard = memo(function AnimationCard({ const [addingProp, setAddingProp] = useState(false); const [addingFromProp, setAddingFromProp] = useState(false); const [expandedKfPct, setExpandedKfPct] = useState(null); + const [focusedCollidingAnimationTargets, setFocusedCollidingAnimationTargets] = useState< + AnimationKeyframeTarget[] | undefined + >(); const cardRef = useRef(null); const pendingAutoScrollRef = useRef(false); @@ -62,6 +70,7 @@ export const AnimationCard = memo(function AnimationCard({ setExpanded(true); pendingAutoScrollRef.current = true; setExpandedKfPct(focusedSegment.tweenPercentage); + setFocusedCollidingAnimationTargets(focusedSegment.collidingAnimationTargets); onFocusSegmentConsumed?.(); }, [focusedSegment, onFocusSegmentConsumed]); @@ -288,9 +297,21 @@ export const AnimationCard = memo(function AnimationCard({ keyframes={animation.keyframes.keyframes} globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"} expandedPct={expandedKfPct} - onToggle={setExpandedKfPct} + collidingAnimationTargets={focusedCollidingAnimationTargets} + onToggle={(pct) => { + setExpandedKfPct(pct); + setFocusedCollidingAnimationTargets(undefined); + }} onEaseCommit={(pct, ease) => { - onUpdateKeyframeEase(animation.id, pct, ease); + if ( + focusedCollidingAnimationTargets && + focusedCollidingAnimationTargets.length > 1 && + onUpdateSegmentEase + ) { + onUpdateSegmentEase(focusedCollidingAnimationTargets, 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 f25aac0c31..59eae3e3fb 100644 --- a/packages/studio/src/components/editor/EaseCurveSection.test.tsx +++ b/packages/studio/src/components/editor/EaseCurveSection.test.tsx @@ -4,6 +4,7 @@ import React, { act, useState } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import { EaseCurveSection, MiniCurveSvg } from "./EaseCurveSection"; +import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -11,12 +12,22 @@ afterEach(() => { document.body.innerHTML = ""; }); -function renderSection(ease = "none", onCustomEaseCommit = vi.fn()) { +function renderSection( + ease = "none", + onCustomEaseCommit = vi.fn(), + collidingAnimationTargets?: AnimationKeyframeTarget[], +) { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { - root.render(); + root.render( + , + ); }); return { host, root, onCustomEaseCommit }; } @@ -82,6 +93,29 @@ function editorLabel(host: HTMLElement): string | null { } describe("EaseCurveSection preset grid", () => { + it("shows the number of animations for a multi-id segment", () => { + const { host, root } = renderSection("power2.out", vi.fn(), [ + { animationId: "move-x", tweenPercentage: 20 }, + { animationId: "move-y", tweenPercentage: 50 }, + { animationId: "fade", tweenPercentage: 80 }, + ]); + + expect(host.textContent).toContain("Applies to 3 animations"); + + act(() => root.unmount()); + }); + + it.each([undefined, [{ animationId: "move-x", tweenPercentage: 20 }]])( + "does not show a property count for a non-colliding segment", + (collidingAnimationTargets) => { + const { host, root } = renderSection("power2.out", vi.fn(), collidingAnimationTargets); + + 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 015ec6b5a1..8c94e8be4d 100644 --- a/packages/studio/src/components/editor/EaseCurveSection.tsx +++ b/packages/studio/src/components/editor/EaseCurveSection.tsx @@ -10,6 +10,7 @@ import { holdCurvePath, MiniCurveSvg, sampledPath } from "./easeCurveSvg"; import { EaseBezierField, SpringBounceField, WiggleField } from "./EaseParamFields"; import { EASE_CURVES, EASE_LABELS, resolveEaseCurveTuple } from "./gsapAnimationConstants"; import { roundToCenti } from "../../utils/rounding"; +import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth"; export { MiniCurveSvg } from "./easeCurveSvg"; @@ -323,9 +324,11 @@ function EaseParameterField({ export function EaseCurveSection({ ease, onCustomEaseCommit, + collidingAnimationTargets, }: { ease: string; onCustomEaseCommit: (ease: string) => void; + collidingAnimationTargets?: AnimationKeyframeTarget[]; }) { const springBounce = parseSpringBounce(ease); const isSpring = springBounce !== null; @@ -419,6 +422,11 @@ export function EaseCurveSection({ return (
+ {collidingAnimationTargets && collidingAnimationTargets.length > 1 && ( +

+ Applies to {collidingAnimationTargets.length} animations +

+ )} {MODE_LABELS[mode]} ease editor selected diff --git a/packages/studio/src/components/editor/GsapAnimationSection.test.tsx b/packages/studio/src/components/editor/GsapAnimationSection.test.tsx new file mode 100644 index 0000000000..7d299e158a --- /dev/null +++ b/packages/studio/src/components/editor/GsapAnimationSection.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext"; +import { usePlayerStore } from "../../player"; +import { GsapAnimationSection } from "./GsapAnimationSection"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("./AnimationCard", () => ({ + AnimationCard: ({ + animation, + focusedSegment, + onFocusSegmentConsumed, + }: { + animation: GsapAnimation; + focusedSegment: { tweenPercentage: number } | null; + onFocusSegmentConsumed: () => void; + }) => ( +