Skip to content

Commit cbd22db

Browse files
committed
fix(studio): switch keyframe ease modes optimistically
1 parent 0165b3a commit cbd22db

7 files changed

Lines changed: 275 additions & 25 deletions

File tree

packages/core/package-subpaths.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@
110110
"types": "./dist/runtime/clipTree.d.ts",
111111
"environments": ["browser", "bun", "node"]
112112
},
113+
"./runtime/custom-ease": {
114+
"source": "./src/runtime/customEase.ts",
115+
"runtime": "./dist/runtime/customEase.js",
116+
"types": "./dist/runtime/customEase.d.ts",
117+
"environments": ["browser", "bun", "node"]
118+
},
113119
"./runtime/start-expression": {
114120
"source": "./src/runtime/startExpression.ts",
115121
"runtime": "./dist/runtime/startExpression.js",

packages/core/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@
119119
"import": "./src/runtime/clipTree.ts",
120120
"types": "./src/runtime/clipTree.ts"
121121
},
122+
"./runtime/custom-ease": {
123+
"bun": "./src/runtime/customEase.ts",
124+
"node": "./dist/runtime/customEase.js",
125+
"import": "./src/runtime/customEase.ts",
126+
"types": "./src/runtime/customEase.ts"
127+
},
122128
"./runtime/start-expression": {
123129
"bun": "./src/runtime/startExpression.ts",
124130
"node": "./dist/runtime/startExpression.js",
@@ -353,6 +359,10 @@
353359
"import": "./dist/runtime/clipTree.js",
354360
"types": "./dist/runtime/clipTree.d.ts"
355361
},
362+
"./runtime/custom-ease": {
363+
"import": "./dist/runtime/customEase.js",
364+
"types": "./dist/runtime/customEase.d.ts"
365+
},
356366
"./runtime/start-expression": {
357367
"import": "./dist/runtime/startExpression.js",
358368
"types": "./dist/runtime/startExpression.d.ts"

packages/core/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
},
1616
"files": [
1717
"src/runtime/clipTree.ts",
18+
"src/runtime/customEase.ts",
1819
"src/runtime/mediaVolumeEnvelope.ts",
1920
"src/runtime/positionEdits.ts",
2021
"src/runtime/protocol.ts",

packages/studio/src/components/editor/AnimationCard.test.tsx

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,15 @@ function findButton(host: HTMLElement, text: string): HTMLButtonElement | undefi
8585
);
8686
}
8787

88+
function openSegment(host: HTMLElement, label: string): void {
89+
const segment = findButton(host, label);
90+
expect(segment).toBeDefined();
91+
act(() => segment?.click());
92+
}
93+
8894
function selectPreset(host: HTMLElement, presetId: string): string {
8995
const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId);
9096
if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`);
91-
9297
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
9398
expect(dropdown).not.toBeNull();
9499
act(() => dropdown?.click());
@@ -174,9 +179,7 @@ describe("AnimationCard", () => {
174179
it("tracks a committed segment ease alongside the existing update", () => {
175180
const onEaseCommit = vi.fn();
176181
const view = renderFocusCard(null, onEaseCommit, true);
177-
const segment = findButton(view.host, "0% → 50%");
178-
expect(segment).toBeDefined();
179-
act(() => segment?.click());
182+
openSegment(view.host, "0% → 50%");
180183
const ease = selectPreset(view.host, "quad-out");
181184

182185
expect(onEaseCommit).toHaveBeenCalledWith(ANIMATION.id, 50, ease);
@@ -221,7 +224,6 @@ describe("AnimationCard", () => {
221224
vi.fn(),
222225
onUpdateSegmentEase,
223226
);
224-
225227
const ease = selectPreset(view.host, "quad-out");
226228

227229
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(ANIMATION.id, 50, ease);
@@ -258,7 +260,40 @@ function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
258260
...overrides,
259261
} as GsapAnimation;
260262
}
263+
261264
describe("AnimationCard ease editing", () => {
265+
it.each([
266+
["spring", "power2.out", "spring(0.42)", "Spring bounce"],
267+
["wiggle", "power2.out", "wiggle(3,easeInOut,0.12)", "Wiggle count"],
268+
["curve", "spring(0.6)", "custom(M0,0 C0.16,1 0.3,1 1,1)", "Cubic bezier control points"],
269+
] as const)(
270+
"commits and immediately displays the %s default when a keyframe segment switches mode",
271+
(mode, currentEase, ease, fieldLabel) => {
272+
const onUpdateKeyframeEase = vi.fn();
273+
const animation = baseAnimation({
274+
keyframes: {
275+
format: "percentage",
276+
keyframes: [
277+
{ percentage: 0, properties: { opacity: 0 } },
278+
{ percentage: 50, properties: { opacity: 0.5 }, ease: currentEase },
279+
{ percentage: 100, properties: { opacity: 1 } },
280+
],
281+
},
282+
});
283+
const view = renderFocusCard(null, onUpdateKeyframeEase, true, animation);
284+
285+
openSegment(view.host, "0% → 50%");
286+
const modeButton = view.host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`);
287+
expect(modeButton).not.toBeNull();
288+
act(() => modeButton?.click());
289+
290+
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
291+
expect(modeButton?.getAttribute("aria-checked")).toBe("true");
292+
expect(view.host.querySelector(`[aria-label="${fieldLabel}"]`)).not.toBeNull();
293+
act(() => view.root.unmount());
294+
},
295+
);
296+
262297
it("commits one preset change to the selected keyframe segment", () => {
263298
const onUpdateKeyframeEase = vi.fn();
264299
const animation = baseAnimation({
@@ -273,11 +308,7 @@ describe("AnimationCard ease editing", () => {
273308
});
274309
const view = renderCard({ animation, onUpdateKeyframeEase });
275310

276-
const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
277-
button.textContent?.includes("0% → 50%"),
278-
);
279-
expect(segment).toBeDefined();
280-
act(() => segment?.click());
311+
openSegment(view.host, "0% → 50%");
281312
const ease = selectPreset(view.host, "quad-out");
282313

283314
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);

packages/studio/src/components/editor/EaseCurveSection.test.tsx

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
import React, { act, useState } from "react";
44
import { createRoot } from "react-dom/client";
55
import { afterEach, describe, expect, it, vi } from "vitest";
6+
import { parseSpringBounce } from "@hyperframes/core/spring-ease";
7+
import { parseWiggleEase } from "@hyperframes/core/wiggle-ease";
68
import { EaseCurveSection, MiniCurveSvg } from "./EaseCurveSection";
9+
import { resolveEaseCurveTuple } from "./gsapAnimationConstants";
710
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
811

912
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -76,6 +79,19 @@ function renderStatefulSection(initialEase = "none", onCustomEaseCommit = vi.fn(
7679
return { host, root, onCustomEaseCommit };
7780
}
7881

82+
function renderControlledSection(initialEase = "none", onCustomEaseCommit = vi.fn()) {
83+
const host = document.createElement("div");
84+
document.body.append(host);
85+
const root = createRoot(host);
86+
const renderEase = (ease: string) => {
87+
act(() =>
88+
root.render(<EaseCurveSection ease={ease} onCustomEaseCommit={onCustomEaseCommit} />),
89+
);
90+
};
91+
renderEase(initialEase);
92+
return { host, root, onCustomEaseCommit, renderEase };
93+
}
94+
7995
function clickMode(host: HTMLElement, mode: "curve" | "spring" | "wiggle"): void {
8096
const toggle = host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`);
8197
expect(toggle).not.toBeNull();
@@ -291,12 +307,107 @@ describe("EaseCurveSection preset grid", () => {
291307

292308
clickMode(host, "spring");
293309
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.42)");
310+
expect(parseSpringBounce(onCustomEaseCommit.mock.lastCall![0])).toBe(0.42);
294311

295312
clickMode(host, "curve");
296313
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.16,1 0.3,1 1,1)");
314+
expect(resolveEaseCurveTuple(onCustomEaseCommit.mock.lastCall![0])).toEqual([0.16, 1, 0.3, 1]);
297315

298316
clickMode(host, "wiggle");
299317
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("wiggle(3,easeInOut,0.12)");
318+
expect(parseWiggleEase(onCustomEaseCommit.mock.lastCall![0])).toEqual({
319+
wiggles: 3,
320+
type: "easeInOut",
321+
amplitude: 0.12,
322+
});
323+
expect(onCustomEaseCommit).toHaveBeenCalledTimes(3);
324+
325+
act(() => root.unmount());
326+
});
327+
328+
it("keeps an optimistic mode visible through its canonical prop round-trip", () => {
329+
const { host, root, onCustomEaseCommit, renderEase } = renderControlledSection();
330+
331+
clickMode(host, "spring");
332+
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
333+
"true",
334+
);
335+
expect(host.querySelector('[aria-label="Spring bounce"]')).not.toBeNull();
336+
337+
renderEase("spring(0.42)");
338+
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
339+
"true",
340+
);
341+
expect(host.querySelector('[aria-label="Spring bounce"]')).not.toBeNull();
342+
expect(onCustomEaseCommit).toHaveBeenCalledExactlyOnceWith("spring(0.42)");
343+
344+
act(() => root.unmount());
345+
});
346+
347+
// Two switches before the first commit round-trips: the commits serialize, so
348+
// the older value arrives while the newer one is still in flight. Repainting
349+
// it would flash wiggle, spring, wiggle in the panel.
350+
it("ignores an older in-flight commit arriving after a newer switch", () => {
351+
const { host, root, renderEase } = renderControlledSection();
352+
353+
clickMode(host, "spring");
354+
clickMode(host, "wiggle");
355+
renderEase("spring(0.42)");
356+
357+
expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe(
358+
"true",
359+
);
360+
361+
renderEase("wiggle(3,easeInOut,0.12)");
362+
expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe(
363+
"true",
364+
);
365+
366+
act(() => root.unmount());
367+
});
368+
369+
// The commit is fire-and-forget: a rejected write or one that lands as a
370+
// no-op never changes `ease`, so nothing else can retire the optimistic
371+
// value and the panel would keep claiming a curve that was never saved.
372+
it("falls back to the committed ease when the commit never round-trips", () => {
373+
vi.useFakeTimers();
374+
try {
375+
const { host, root } = renderControlledSection("power2.out");
376+
377+
clickMode(host, "spring");
378+
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
379+
"true",
380+
);
381+
382+
act(() => vi.advanceTimersByTime(2000));
383+
384+
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
385+
"false",
386+
);
387+
expect(host.querySelector('[data-ease-mode="curve"]')?.getAttribute("aria-checked")).toBe(
388+
"true",
389+
);
390+
391+
act(() => root.unmount());
392+
} finally {
393+
vi.useRealTimers();
394+
}
395+
});
396+
397+
it("replaces an optimistic mode when the canonical prop changes externally", () => {
398+
const { host, root, renderEase } = renderControlledSection();
399+
400+
clickMode(host, "spring");
401+
renderEase("wiggle(2,uniform,0.3)");
402+
403+
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
404+
"false",
405+
);
406+
expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe(
407+
"true",
408+
);
409+
expect(host.querySelector('[aria-label="Wiggle count"]')).not.toBeNull();
410+
expect(host.querySelector('[aria-label="Spring bounce"]')).toBeNull();
300411

301412
act(() => root.unmount());
302413
});

0 commit comments

Comments
 (0)