diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index f3505daa6a..62bc92fe03 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -110,6 +110,12 @@ "types": "./dist/runtime/clipTree.d.ts", "environments": ["browser", "bun", "node"] }, + "./runtime/custom-ease": { + "source": "./src/runtime/customEase.ts", + "runtime": "./dist/runtime/customEase.js", + "types": "./dist/runtime/customEase.d.ts", + "environments": ["browser", "bun", "node"] + }, "./runtime/start-expression": { "source": "./src/runtime/startExpression.ts", "runtime": "./dist/runtime/startExpression.js", diff --git a/packages/core/package.json b/packages/core/package.json index 3255b1fe1d..f33dee17c4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -119,6 +119,12 @@ "import": "./src/runtime/clipTree.ts", "types": "./src/runtime/clipTree.ts" }, + "./runtime/custom-ease": { + "bun": "./src/runtime/customEase.ts", + "node": "./dist/runtime/customEase.js", + "import": "./src/runtime/customEase.ts", + "types": "./src/runtime/customEase.ts" + }, "./runtime/start-expression": { "bun": "./src/runtime/startExpression.ts", "node": "./dist/runtime/startExpression.js", @@ -353,6 +359,10 @@ "import": "./dist/runtime/clipTree.js", "types": "./dist/runtime/clipTree.d.ts" }, + "./runtime/custom-ease": { + "import": "./dist/runtime/customEase.js", + "types": "./dist/runtime/customEase.d.ts" + }, "./runtime/start-expression": { "import": "./dist/runtime/startExpression.js", "types": "./dist/runtime/startExpression.d.ts" diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index d37173bc73..571e3c2a63 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -15,6 +15,7 @@ }, "files": [ "src/runtime/clipTree.ts", + "src/runtime/customEase.ts", "src/runtime/mediaVolumeEnvelope.ts", "src/runtime/positionEdits.ts", "src/runtime/protocol.ts", 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..65cf0658b6 100644 --- a/packages/studio/src/components/editor/AnimationCard.test.tsx +++ b/packages/studio/src/components/editor/AnimationCard.test.tsx @@ -2,39 +2,98 @@ 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 openSegment(host: HTMLElement, label: string): void { + const segment = findButton(host, label); + expect(segment).toBeDefined(); + act(() => segment?.click()); +} 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,6 +104,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,7 +143,157 @@ 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); + openSegment(view.host, "0% → 50%"); + 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.each([ + ["spring", "power2.out", "spring(0.42)", "Spring bounce"], + ["wiggle", "power2.out", "wiggle(3,easeInOut,0.12)", "Wiggle count"], + ["curve", "spring(0.6)", "custom(M0,0 C0.16,1 0.3,1 1,1)", "Cubic bezier control points"], + ] as const)( + "commits and immediately displays the %s default when a keyframe segment switches mode", + (mode, currentEase, ease, fieldLabel) => { + const onUpdateKeyframeEase = vi.fn(); + const animation = baseAnimation({ + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 50, properties: { opacity: 0.5 }, ease: currentEase }, + { percentage: 100, properties: { opacity: 1 } }, + ], + }, + }); + const view = renderFocusCard(null, onUpdateKeyframeEase, true, animation); + + openSegment(view.host, "0% → 50%"); + const modeButton = view.host.querySelector(`[data-ease-mode="${mode}"]`); + expect(modeButton).not.toBeNull(); + act(() => modeButton?.click()); + + expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease); + expect(modeButton?.getAttribute("aria-checked")).toBe("true"); + expect(view.host.querySelector(`[aria-label="${fieldLabel}"]`)).not.toBeNull(); + act(() => view.root.unmount()); + }, + ); + it("commits one preset change to the selected keyframe segment", () => { const onUpdateKeyframeEase = vi.fn(); const animation = baseAnimation({ @@ -97,11 +308,7 @@ describe("AnimationCard ease editing", () => { }); const view = renderCard({ animation, onUpdateKeyframeEase }); - const segment = Array.from(view.host.querySelectorAll("button")).find((button) => - button.textContent?.includes("0% → 50%"), - ); - expect(segment).toBeDefined(); - act(() => segment?.click()); + openSegment(view.host, "0% → 50%"); const ease = selectPreset(view.host, "quad-out"); expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease); 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..484392fe17 100644 --- a/packages/studio/src/components/editor/EaseCurveSection.test.tsx +++ b/packages/studio/src/components/editor/EaseCurveSection.test.tsx @@ -3,7 +3,11 @@ import React, { act, useState } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { parseSpringBounce } from "@hyperframes/core/spring-ease"; +import { parseWiggleEase } from "@hyperframes/core/wiggle-ease"; import { EaseCurveSection, MiniCurveSvg } from "./EaseCurveSection"; +import { resolveEaseCurveTuple } from "./gsapAnimationConstants"; +import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -11,12 +15,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 }; } @@ -65,6 +79,19 @@ function renderStatefulSection(initialEase = "none", onCustomEaseCommit = vi.fn( return { host, root, onCustomEaseCommit }; } +function renderControlledSection(initialEase = "none", onCustomEaseCommit = vi.fn()) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const renderEase = (ease: string) => { + act(() => + root.render(), + ); + }; + renderEase(initialEase); + return { host, root, onCustomEaseCommit, renderEase }; +} + function clickMode(host: HTMLElement, mode: "curve" | "spring" | "wiggle"): void { const toggle = host.querySelector(`[data-ease-mode="${mode}"]`); expect(toggle).not.toBeNull(); @@ -82,6 +109,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"]], @@ -257,12 +307,107 @@ describe("EaseCurveSection preset grid", () => { clickMode(host, "spring"); expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.42)"); + expect(parseSpringBounce(onCustomEaseCommit.mock.lastCall![0])).toBe(0.42); clickMode(host, "curve"); expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.16,1 0.3,1 1,1)"); + expect(resolveEaseCurveTuple(onCustomEaseCommit.mock.lastCall![0])).toEqual([0.16, 1, 0.3, 1]); clickMode(host, "wiggle"); expect(onCustomEaseCommit).toHaveBeenLastCalledWith("wiggle(3,easeInOut,0.12)"); + expect(parseWiggleEase(onCustomEaseCommit.mock.lastCall![0])).toEqual({ + wiggles: 3, + type: "easeInOut", + amplitude: 0.12, + }); + expect(onCustomEaseCommit).toHaveBeenCalledTimes(3); + + act(() => root.unmount()); + }); + + it("keeps an optimistic mode visible through its canonical prop round-trip", () => { + const { host, root, onCustomEaseCommit, renderEase } = renderControlledSection(); + + clickMode(host, "spring"); + expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe( + "true", + ); + expect(host.querySelector('[aria-label="Spring bounce"]')).not.toBeNull(); + + renderEase("spring(0.42)"); + expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe( + "true", + ); + expect(host.querySelector('[aria-label="Spring bounce"]')).not.toBeNull(); + expect(onCustomEaseCommit).toHaveBeenCalledExactlyOnceWith("spring(0.42)"); + + act(() => root.unmount()); + }); + + // Two switches before the first commit round-trips: the commits serialize, so + // the older value arrives while the newer one is still in flight. Repainting + // it would flash wiggle, spring, wiggle in the panel. + it("ignores an older in-flight commit arriving after a newer switch", () => { + const { host, root, renderEase } = renderControlledSection(); + + clickMode(host, "spring"); + clickMode(host, "wiggle"); + renderEase("spring(0.42)"); + + expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe( + "true", + ); + + renderEase("wiggle(3,easeInOut,0.12)"); + expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe( + "true", + ); + + act(() => root.unmount()); + }); + + // The commit is fire-and-forget: a rejected write or one that lands as a + // no-op never changes `ease`, so nothing else can retire the optimistic + // value and the panel would keep claiming a curve that was never saved. + it("falls back to the committed ease when the commit never round-trips", () => { + vi.useFakeTimers(); + try { + const { host, root } = renderControlledSection("power2.out"); + + clickMode(host, "spring"); + expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe( + "true", + ); + + act(() => vi.advanceTimersByTime(2000)); + + expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe( + "false", + ); + expect(host.querySelector('[data-ease-mode="curve"]')?.getAttribute("aria-checked")).toBe( + "true", + ); + + act(() => root.unmount()); + } finally { + vi.useRealTimers(); + } + }); + + it("replaces an optimistic mode when the canonical prop changes externally", () => { + const { host, root, renderEase } = renderControlledSection(); + + clickMode(host, "spring"); + renderEase("wiggle(2,uniform,0.3)"); + + expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe( + "false", + ); + expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe( + "true", + ); + expect(host.querySelector('[aria-label="Wiggle count"]')).not.toBeNull(); + expect(host.querySelector('[aria-label="Spring bounce"]')).toBeNull(); act(() => root.unmount()); }); diff --git a/packages/studio/src/components/editor/EaseCurveSection.tsx b/packages/studio/src/components/editor/EaseCurveSection.tsx index 015ec6b5a1..f2f6ce0300 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"; @@ -320,19 +321,36 @@ function EaseParameterField({ return ; } +/** + * How long an optimistically painted ease may outlive its commit. Long enough + * for a normal write-reparse-rerender round trip, short enough that a dropped + * write self-corrects while the author is still looking at the panel. + */ +const PENDING_EASE_TIMEOUT_MS = 2000; + export function EaseCurveSection({ ease, onCustomEaseCommit, + collidingAnimationTargets, }: { ease: string; onCustomEaseCommit: (ease: string) => void; + collidingAnimationTargets?: AnimationKeyframeTarget[]; }) { - const springBounce = parseSpringBounce(ease); + // The ease this section painted optimistically, still waiting for its commit + // to round-trip back through the `ease` prop. + const [pendingEase, setPendingEase] = useState(null); + // Every value committed and not yet seen coming back, oldest first. It takes + // the whole queue, not just the latest, to tell an older commit echoing back + // apart from an edit made somewhere else. + const inFlightEasesRef = useRef([]); + const displayedEase = pendingEase ?? ease; + const springBounce = parseSpringBounce(displayedEase); const isSpring = springBounce !== null; - const wiggleConfig = parseWiggleEase(ease); + const wiggleConfig = parseWiggleEase(displayedEase); const isWiggle = wiggleConfig !== null; const mode: EaseMode = isSpring ? "spring" : isWiggle ? "wiggle" : "curve"; - const curve = resolveEditableCurve(ease, springBounce); + const curve = resolveEditableCurve(displayedEase, springBounce); const [draft, setDraft] = useState(null); const [hover, setHover] = useState<"p1" | "p2" | null>(null); @@ -346,8 +364,43 @@ export function EaseCurveSection({ // `ease` changes, `curve` already equals the draft, so the handoff is seamless. useEffect(() => { setDraft(null); + const inFlight = inFlightEasesRef.current; + const landed = inFlight.indexOf(ease); + if (landed < 0) { + // A value this section never sent: someone else edited the ease, so the + // real value wins over anything optimistic still on screen. + inFlight.length = 0; + setPendingEase(null); + return; + } + // One of this section's own commits came back. Everything sent before it + // is settled with it, but a NEWER commit may still be in flight, and + // repainting this older value while waiting for that one is the + // wiggle-then-spring-then-wiggle flicker of a fast double switch. + inFlight.splice(0, landed + 1); + if (inFlight.length === 0) setPendingEase(null); }, [ease]); + // A commit is fire-and-forget, so a write that is rejected or lands as a + // no-op never changes `ease`, and the optimistic value would sit on screen + // claiming a curve the composition does not have. Nothing downstream reports + // that failure, so the display is time-bounded instead: fall back to the + // committed truth when the round trip does not arrive. + useEffect(() => { + if (pendingEase === null) return; + const timer = setTimeout(() => { + inFlightEasesRef.current.length = 0; + setPendingEase(null); + }, PENDING_EASE_TIMEOUT_MS); + return () => clearTimeout(timer); + }, [pendingEase]); + + const commitEase = (nextEase: string) => { + inFlightEasesRef.current.push(nextEase); + setPendingEase(nextEase); + onCustomEaseCommit(nextEase); + }; + const activeTuple = draft ?? curve; const displayTuple = activeTuple ?? DEFAULT_CURVE; const [x1, y1, x2, y2] = displayTuple; @@ -358,8 +411,12 @@ export function EaseCurveSection({ const a1 = { x: xToSvg(1), y: yToSvg(1) }; const p1 = { x: xToSvg(x1), y: yToSvg(clampView(y1)) }; const p2 = { x: xToSvg(x2), y: yToSvg(clampView(y2)) }; - const curvePath = curvePathFor(ease, springBounce, wiggleConfig, displayTuple); - const showGraph = activeTuple !== null || isWiggle || ease === "hold"; + // Read the OPTIMISTIC ease everywhere the graph is derived, so a mode switch + // paints immediately instead of waiting for the committed prop to come back. + const curvePath = curvePathFor(displayedEase, springBounce, wiggleConfig, displayTuple); + const showGraph = activeTuple !== null || isWiggle || displayedEase === "hold"; + // `curve !== null` is what keeps Hold handle-free: it draws a graph (a flat + // step) but has no editable control points to drag. const showHandles = curve !== null && !isSpring && !isWiggle; const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => { @@ -394,10 +451,9 @@ export function EaseCurveSection({ if (!draggingRef.current || !draft) return; draggingRef.current = null; const path = `M0,0 C${draft[0]},${draft[1]} ${draft[2]},${draft[3]} 1,1`; - // Clear after the synchronous parent commit settles. This also clears a - // same-string commit, where the `ease` dependency effect would not run. - onCustomEaseCommit(`custom(${path})`); - queueMicrotask(() => setDraft(null)); + // Commit only — the draft stays on screen and is cleared by the effect above + // once the committed `ease` prop comes back, so the curve never flickers. + commitEase(`custom(${path})`); }; const handleKeyDown = (handle: "p1" | "p2", event: React.KeyboardEvent) => { @@ -406,20 +462,26 @@ export function EaseCurveSection({ event.preventDefault(); event.stopPropagation(); setDraft(next); - onCustomEaseCommit(`custom(M0,0 C${next[0]},${next[1]} ${next[2]},${next[3]} 1,1)`); - queueMicrotask(() => setDraft(null)); + // Same no-flicker contract as the pointer path: commit and let the effect + // clear the draft, rather than dropping it on the next microtask. + commitEase(`custom(M0,0 C${next[0]},${next[1]} ${next[2]},${next[3]} 1,1)`); }; const top = yToSvg(1); const bottom = yToSvg(0); const left = xToSvg(0); const right = xToSvg(1); - const label = resolveEditorLabel(ease, springBounce, isWiggle); + const label = resolveEditorLabel(displayedEase, springBounce, isWiggle); return (
- - + + {collidingAnimationTargets && collidingAnimationTargets.length > 1 && ( +

+ Applies to {collidingAnimationTargets.length} animations +

+ )} + {MODE_LABELS[mode]} ease editor selected @@ -560,7 +622,7 @@ export function EaseCurveSection({ springBounce={springBounce} wiggleConfig={wiggleConfig} tuple={displayTuple} - onCommit={onCustomEaseCommit} + onCommit={commitEase} /> ) : ( 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; + }) => ( + + {onDelete && ( + + )}