Skip to content

Commit c9e12e8

Browse files
committed
fix(studio): switch keyframe ease modes optimistically
1 parent 899af39 commit c9e12e8

4 files changed

Lines changed: 163 additions & 25 deletions

File tree

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: 61 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,57 @@ 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+
it("replaces an optimistic mode when the canonical prop changes externally", () => {
348+
const { host, root, renderEase } = renderControlledSection();
349+
350+
clickMode(host, "spring");
351+
renderEase("wiggle(2,uniform,0.3)");
352+
353+
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
354+
"false",
355+
);
356+
expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe(
357+
"true",
358+
);
359+
expect(host.querySelector('[aria-label="Wiggle count"]')).not.toBeNull();
360+
expect(host.querySelector('[aria-label="Spring bounce"]')).toBeNull();
300361

301362
act(() => root.unmount());
302363
});

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

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -330,12 +330,14 @@ export function EaseCurveSection({
330330
onCustomEaseCommit: (ease: string) => void;
331331
collidingAnimationTargets?: AnimationKeyframeTarget[];
332332
}) {
333-
const springBounce = parseSpringBounce(ease);
333+
const [pendingEase, setPendingEase] = useState<{ source: string; value: string } | null>(null);
334+
const displayedEase = pendingEase?.source === ease ? pendingEase.value : ease;
335+
const springBounce = parseSpringBounce(displayedEase);
334336
const isSpring = springBounce !== null;
335-
const wiggleConfig = parseWiggleEase(ease);
337+
const wiggleConfig = parseWiggleEase(displayedEase);
336338
const isWiggle = wiggleConfig !== null;
337339
const mode: EaseMode = isSpring ? "spring" : isWiggle ? "wiggle" : "curve";
338-
const curve = resolveEditableCurve(ease, springBounce);
340+
const curve = resolveEditableCurve(displayedEase, springBounce);
339341

340342
const [draft, setDraft] = useState<Pts | null>(null);
341343
const [hover, setHover] = useState<"p1" | "p2" | null>(null);
@@ -349,8 +351,14 @@ export function EaseCurveSection({
349351
// `ease` changes, `curve` already equals the draft, so the handoff is seamless.
350352
useEffect(() => {
351353
setDraft(null);
354+
setPendingEase(null);
352355
}, [ease]);
353356

357+
const commitEase = (nextEase: string) => {
358+
setPendingEase({ source: ease, value: nextEase });
359+
onCustomEaseCommit(nextEase);
360+
};
361+
354362
const activeTuple = draft ?? curve;
355363
const displayTuple = activeTuple ?? DEFAULT_CURVE;
356364
const [x1, y1, x2, y2] = displayTuple;
@@ -361,8 +369,12 @@ export function EaseCurveSection({
361369
const a1 = { x: xToSvg(1), y: yToSvg(1) };
362370
const p1 = { x: xToSvg(x1), y: yToSvg(clampView(y1)) };
363371
const p2 = { x: xToSvg(x2), y: yToSvg(clampView(y2)) };
364-
const curvePath = curvePathFor(ease, springBounce, wiggleConfig, displayTuple);
365-
const showGraph = activeTuple !== null || isWiggle || ease === "hold";
372+
// Read the OPTIMISTIC ease everywhere the graph is derived, so a mode switch
373+
// paints immediately instead of waiting for the committed prop to come back.
374+
const curvePath = curvePathFor(displayedEase, springBounce, wiggleConfig, displayTuple);
375+
const showGraph = activeTuple !== null || isWiggle || displayedEase === "hold";
376+
// `curve !== null` is what keeps Hold handle-free: it draws a graph (a flat
377+
// step) but has no editable control points to drag.
366378
const showHandles = curve !== null && !isSpring && !isWiggle;
367379

368380
const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => {
@@ -397,10 +409,9 @@ export function EaseCurveSection({
397409
if (!draggingRef.current || !draft) return;
398410
draggingRef.current = null;
399411
const path = `M0,0 C${draft[0]},${draft[1]} ${draft[2]},${draft[3]} 1,1`;
400-
// Clear after the synchronous parent commit settles. This also clears a
401-
// same-string commit, where the `ease` dependency effect would not run.
402-
onCustomEaseCommit(`custom(${path})`);
403-
queueMicrotask(() => setDraft(null));
412+
// Commit only — the draft stays on screen and is cleared by the effect above
413+
// once the committed `ease` prop comes back, so the curve never flickers.
414+
commitEase(`custom(${path})`);
404415
};
405416

406417
const handleKeyDown = (handle: "p1" | "p2", event: React.KeyboardEvent<SVGCircleElement>) => {
@@ -409,25 +420,26 @@ export function EaseCurveSection({
409420
event.preventDefault();
410421
event.stopPropagation();
411422
setDraft(next);
412-
onCustomEaseCommit(`custom(M0,0 C${next[0]},${next[1]} ${next[2]},${next[3]} 1,1)`);
413-
queueMicrotask(() => setDraft(null));
423+
// Same no-flicker contract as the pointer path: commit and let the effect
424+
// clear the draft, rather than dropping it on the next microtask.
425+
commitEase(`custom(M0,0 C${next[0]},${next[1]} ${next[2]},${next[3]} 1,1)`);
414426
};
415427

416428
const top = yToSvg(1);
417429
const bottom = yToSvg(0);
418430
const left = xToSvg(0);
419431
const right = xToSvg(1);
420-
const label = resolveEditorLabel(ease, springBounce, isWiggle);
432+
const label = resolveEditorLabel(displayedEase, springBounce, isWiggle);
421433

422434
return (
423435
<div className="rounded-lg bg-neutral-900/50 p-2">
424-
<EaseTypeDropdown kind={mode} ease={ease} label={label} onSelect={onCustomEaseCommit} />
436+
<EaseTypeDropdown kind={mode} ease={displayedEase} label={label} onSelect={commitEase} />
425437
{collidingAnimationTargets && collidingAnimationTargets.length > 1 && (
426438
<p className="mb-1 text-[9px] text-neutral-500">
427439
Applies to {collidingAnimationTargets.length} properties
428440
</p>
429441
)}
430-
<EaseModeToggle mode={mode} onCommit={onCustomEaseCommit} />
442+
<EaseModeToggle mode={mode} onCommit={commitEase} />
431443
<span className="sr-only" aria-live="polite">
432444
{MODE_LABELS[mode]} ease editor selected
433445
</span>
@@ -568,7 +580,7 @@ export function EaseCurveSection({
568580
springBounce={springBounce}
569581
wiggleConfig={wiggleConfig}
570582
tuple={displayTuple}
571-
onCommit={onCustomEaseCommit}
583+
onCommit={commitEase}
572584
/>
573585
</>
574586
) : (
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { installStudioCustomEase } from "../../../../core/src/runtime/customEase";
2+
import { gsap } from "gsap";
3+
import { describe, expect, it } from "vitest";
4+
5+
describe("Studio hold ease", () => {
6+
it("holds the start value under seek until the destination time", () => {
7+
const runtimeGsap = { parseEase: gsap.parseEase.bind(gsap) };
8+
expect(installStudioCustomEase(runtimeGsap)).toBe(true);
9+
const hold = runtimeGsap.parseEase("hold");
10+
expect(hold).toBeTypeOf("function");
11+
if (typeof hold !== "function") return;
12+
13+
const target = { value: 0 };
14+
const timeline = gsap.timeline({ paused: true }).to(
15+
target,
16+
{
17+
value: 100,
18+
duration: 2,
19+
ease: hold,
20+
},
21+
0,
22+
);
23+
24+
timeline.seek(0.5);
25+
expect(target.value).toBe(0);
26+
timeline.seek(1);
27+
expect(target.value).toBe(0);
28+
timeline.seek(1.99);
29+
expect(target.value).toBe(0);
30+
timeline.seek(2);
31+
expect(target.value).toBe(100);
32+
timeline.kill();
33+
});
34+
});

0 commit comments

Comments
 (0)