mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
fix(studio): switch keyframe ease modes optimistically
This commit is contained in:
@@ -110,6 +110,12 @@
|
|||||||
"types": "./dist/runtime/clipTree.d.ts",
|
"types": "./dist/runtime/clipTree.d.ts",
|
||||||
"environments": ["browser", "bun", "node"]
|
"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": {
|
"./runtime/start-expression": {
|
||||||
"source": "./src/runtime/startExpression.ts",
|
"source": "./src/runtime/startExpression.ts",
|
||||||
"runtime": "./dist/runtime/startExpression.js",
|
"runtime": "./dist/runtime/startExpression.js",
|
||||||
|
|||||||
@@ -119,6 +119,12 @@
|
|||||||
"import": "./src/runtime/clipTree.ts",
|
"import": "./src/runtime/clipTree.ts",
|
||||||
"types": "./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": {
|
"./runtime/start-expression": {
|
||||||
"bun": "./src/runtime/startExpression.ts",
|
"bun": "./src/runtime/startExpression.ts",
|
||||||
"node": "./dist/runtime/startExpression.js",
|
"node": "./dist/runtime/startExpression.js",
|
||||||
@@ -353,6 +359,10 @@
|
|||||||
"import": "./dist/runtime/clipTree.js",
|
"import": "./dist/runtime/clipTree.js",
|
||||||
"types": "./dist/runtime/clipTree.d.ts"
|
"types": "./dist/runtime/clipTree.d.ts"
|
||||||
},
|
},
|
||||||
|
"./runtime/custom-ease": {
|
||||||
|
"import": "./dist/runtime/customEase.js",
|
||||||
|
"types": "./dist/runtime/customEase.d.ts"
|
||||||
|
},
|
||||||
"./runtime/start-expression": {
|
"./runtime/start-expression": {
|
||||||
"import": "./dist/runtime/startExpression.js",
|
"import": "./dist/runtime/startExpression.js",
|
||||||
"types": "./dist/runtime/startExpression.d.ts"
|
"types": "./dist/runtime/startExpression.d.ts"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"src/runtime/clipTree.ts",
|
"src/runtime/clipTree.ts",
|
||||||
|
"src/runtime/customEase.ts",
|
||||||
"src/runtime/mediaVolumeEnvelope.ts",
|
"src/runtime/mediaVolumeEnvelope.ts",
|
||||||
"src/runtime/positionEdits.ts",
|
"src/runtime/positionEdits.ts",
|
||||||
"src/runtime/protocol.ts",
|
"src/runtime/protocol.ts",
|
||||||
|
|||||||
@@ -85,10 +85,15 @@ function findButton(host: HTMLElement, text: string): HTMLButtonElement | undefi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function selectPreset(host: HTMLElement, presetId: string): string {
|
||||||
const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId);
|
const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId);
|
||||||
if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`);
|
if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`);
|
||||||
|
|
||||||
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
|
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
|
||||||
expect(dropdown).not.toBeNull();
|
expect(dropdown).not.toBeNull();
|
||||||
act(() => dropdown?.click());
|
act(() => dropdown?.click());
|
||||||
@@ -174,9 +179,7 @@ describe("AnimationCard", () => {
|
|||||||
it("tracks a committed segment ease alongside the existing update", () => {
|
it("tracks a committed segment ease alongside the existing update", () => {
|
||||||
const onEaseCommit = vi.fn();
|
const onEaseCommit = vi.fn();
|
||||||
const view = renderFocusCard(null, onEaseCommit, true);
|
const view = renderFocusCard(null, onEaseCommit, true);
|
||||||
const segment = findButton(view.host, "0% → 50%");
|
openSegment(view.host, "0% → 50%");
|
||||||
expect(segment).toBeDefined();
|
|
||||||
act(() => segment?.click());
|
|
||||||
const ease = selectPreset(view.host, "quad-out");
|
const ease = selectPreset(view.host, "quad-out");
|
||||||
|
|
||||||
expect(onEaseCommit).toHaveBeenCalledWith(ANIMATION.id, 50, ease);
|
expect(onEaseCommit).toHaveBeenCalledWith(ANIMATION.id, 50, ease);
|
||||||
@@ -221,7 +224,6 @@ describe("AnimationCard", () => {
|
|||||||
vi.fn(),
|
vi.fn(),
|
||||||
onUpdateSegmentEase,
|
onUpdateSegmentEase,
|
||||||
);
|
);
|
||||||
|
|
||||||
const ease = selectPreset(view.host, "quad-out");
|
const ease = selectPreset(view.host, "quad-out");
|
||||||
|
|
||||||
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(ANIMATION.id, 50, ease);
|
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(ANIMATION.id, 50, ease);
|
||||||
@@ -258,7 +260,40 @@ function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
|
|||||||
...overrides,
|
...overrides,
|
||||||
} as GsapAnimation;
|
} as GsapAnimation;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("AnimationCard ease editing", () => {
|
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<HTMLButtonElement>(`[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", () => {
|
it("commits one preset change to the selected keyframe segment", () => {
|
||||||
const onUpdateKeyframeEase = vi.fn();
|
const onUpdateKeyframeEase = vi.fn();
|
||||||
const animation = baseAnimation({
|
const animation = baseAnimation({
|
||||||
@@ -273,11 +308,7 @@ describe("AnimationCard ease editing", () => {
|
|||||||
});
|
});
|
||||||
const view = renderCard({ animation, onUpdateKeyframeEase });
|
const view = renderCard({ animation, onUpdateKeyframeEase });
|
||||||
|
|
||||||
const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
|
openSegment(view.host, "0% → 50%");
|
||||||
button.textContent?.includes("0% → 50%"),
|
|
||||||
);
|
|
||||||
expect(segment).toBeDefined();
|
|
||||||
act(() => segment?.click());
|
|
||||||
const ease = selectPreset(view.host, "quad-out");
|
const ease = selectPreset(view.host, "quad-out");
|
||||||
|
|
||||||
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
|
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
import React, { act, useState } from "react";
|
import React, { act, useState } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
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 { EaseCurveSection, MiniCurveSvg } from "./EaseCurveSection";
|
||||||
|
import { resolveEaseCurveTuple } from "./gsapAnimationConstants";
|
||||||
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
||||||
|
|
||||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(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(
|
|||||||
return { host, root, onCustomEaseCommit };
|
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(<EaseCurveSection ease={ease} onCustomEaseCommit={onCustomEaseCommit} />),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
renderEase(initialEase);
|
||||||
|
return { host, root, onCustomEaseCommit, renderEase };
|
||||||
|
}
|
||||||
|
|
||||||
function clickMode(host: HTMLElement, mode: "curve" | "spring" | "wiggle"): void {
|
function clickMode(host: HTMLElement, mode: "curve" | "spring" | "wiggle"): void {
|
||||||
const toggle = host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`);
|
const toggle = host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`);
|
||||||
expect(toggle).not.toBeNull();
|
expect(toggle).not.toBeNull();
|
||||||
@@ -291,12 +307,107 @@ describe("EaseCurveSection preset grid", () => {
|
|||||||
|
|
||||||
clickMode(host, "spring");
|
clickMode(host, "spring");
|
||||||
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.42)");
|
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.42)");
|
||||||
|
expect(parseSpringBounce(onCustomEaseCommit.mock.lastCall![0])).toBe(0.42);
|
||||||
|
|
||||||
clickMode(host, "curve");
|
clickMode(host, "curve");
|
||||||
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.16,1 0.3,1 1,1)");
|
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");
|
clickMode(host, "wiggle");
|
||||||
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("wiggle(3,easeInOut,0.12)");
|
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());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -321,6 +321,13 @@ function EaseParameterField({
|
|||||||
return <EaseBezierField tuple={tuple} onCommit={onCommit} />;
|
return <EaseBezierField tuple={tuple} onCommit={onCommit} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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({
|
export function EaseCurveSection({
|
||||||
ease,
|
ease,
|
||||||
onCustomEaseCommit,
|
onCustomEaseCommit,
|
||||||
@@ -330,12 +337,20 @@ export function EaseCurveSection({
|
|||||||
onCustomEaseCommit: (ease: string) => void;
|
onCustomEaseCommit: (ease: string) => void;
|
||||||
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
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<string | null>(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<string[]>([]);
|
||||||
|
const displayedEase = pendingEase ?? ease;
|
||||||
|
const springBounce = parseSpringBounce(displayedEase);
|
||||||
const isSpring = springBounce !== null;
|
const isSpring = springBounce !== null;
|
||||||
const wiggleConfig = parseWiggleEase(ease);
|
const wiggleConfig = parseWiggleEase(displayedEase);
|
||||||
const isWiggle = wiggleConfig !== null;
|
const isWiggle = wiggleConfig !== null;
|
||||||
const mode: EaseMode = isSpring ? "spring" : isWiggle ? "wiggle" : "curve";
|
const mode: EaseMode = isSpring ? "spring" : isWiggle ? "wiggle" : "curve";
|
||||||
const curve = resolveEditableCurve(ease, springBounce);
|
const curve = resolveEditableCurve(displayedEase, springBounce);
|
||||||
|
|
||||||
const [draft, setDraft] = useState<Pts | null>(null);
|
const [draft, setDraft] = useState<Pts | null>(null);
|
||||||
const [hover, setHover] = useState<"p1" | "p2" | null>(null);
|
const [hover, setHover] = useState<"p1" | "p2" | null>(null);
|
||||||
@@ -349,8 +364,43 @@ export function EaseCurveSection({
|
|||||||
// `ease` changes, `curve` already equals the draft, so the handoff is seamless.
|
// `ease` changes, `curve` already equals the draft, so the handoff is seamless.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraft(null);
|
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]);
|
}, [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 activeTuple = draft ?? curve;
|
||||||
const displayTuple = activeTuple ?? DEFAULT_CURVE;
|
const displayTuple = activeTuple ?? DEFAULT_CURVE;
|
||||||
const [x1, y1, x2, y2] = displayTuple;
|
const [x1, y1, x2, y2] = displayTuple;
|
||||||
@@ -361,8 +411,12 @@ export function EaseCurveSection({
|
|||||||
const a1 = { x: xToSvg(1), y: yToSvg(1) };
|
const a1 = { x: xToSvg(1), y: yToSvg(1) };
|
||||||
const p1 = { x: xToSvg(x1), y: yToSvg(clampView(y1)) };
|
const p1 = { x: xToSvg(x1), y: yToSvg(clampView(y1)) };
|
||||||
const p2 = { x: xToSvg(x2), y: yToSvg(clampView(y2)) };
|
const p2 = { x: xToSvg(x2), y: yToSvg(clampView(y2)) };
|
||||||
const curvePath = curvePathFor(ease, springBounce, wiggleConfig, displayTuple);
|
// Read the OPTIMISTIC ease everywhere the graph is derived, so a mode switch
|
||||||
const showGraph = activeTuple !== null || isWiggle || ease === "hold";
|
// 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 showHandles = curve !== null && !isSpring && !isWiggle;
|
||||||
|
|
||||||
const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => {
|
const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => {
|
||||||
@@ -397,10 +451,9 @@ export function EaseCurveSection({
|
|||||||
if (!draggingRef.current || !draft) return;
|
if (!draggingRef.current || !draft) return;
|
||||||
draggingRef.current = null;
|
draggingRef.current = null;
|
||||||
const path = `M0,0 C${draft[0]},${draft[1]} ${draft[2]},${draft[3]} 1,1`;
|
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
|
// Commit only — the draft stays on screen and is cleared by the effect above
|
||||||
// same-string commit, where the `ease` dependency effect would not run.
|
// once the committed `ease` prop comes back, so the curve never flickers.
|
||||||
onCustomEaseCommit(`custom(${path})`);
|
commitEase(`custom(${path})`);
|
||||||
queueMicrotask(() => setDraft(null));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (handle: "p1" | "p2", event: React.KeyboardEvent<SVGCircleElement>) => {
|
const handleKeyDown = (handle: "p1" | "p2", event: React.KeyboardEvent<SVGCircleElement>) => {
|
||||||
@@ -409,25 +462,26 @@ export function EaseCurveSection({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setDraft(next);
|
setDraft(next);
|
||||||
onCustomEaseCommit(`custom(M0,0 C${next[0]},${next[1]} ${next[2]},${next[3]} 1,1)`);
|
// Same no-flicker contract as the pointer path: commit and let the effect
|
||||||
queueMicrotask(() => setDraft(null));
|
// 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 top = yToSvg(1);
|
||||||
const bottom = yToSvg(0);
|
const bottom = yToSvg(0);
|
||||||
const left = xToSvg(0);
|
const left = xToSvg(0);
|
||||||
const right = xToSvg(1);
|
const right = xToSvg(1);
|
||||||
const label = resolveEditorLabel(ease, springBounce, isWiggle);
|
const label = resolveEditorLabel(displayedEase, springBounce, isWiggle);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-neutral-900/50 p-2">
|
<div className="rounded-lg bg-neutral-900/50 p-2">
|
||||||
<EaseTypeDropdown kind={mode} ease={ease} label={label} onSelect={onCustomEaseCommit} />
|
<EaseTypeDropdown kind={mode} ease={displayedEase} label={label} onSelect={commitEase} />
|
||||||
{collidingAnimationTargets && collidingAnimationTargets.length > 1 && (
|
{collidingAnimationTargets && collidingAnimationTargets.length > 1 && (
|
||||||
<p className="mb-1 text-[9px] text-neutral-500">
|
<p className="mb-1 text-[9px] text-neutral-500">
|
||||||
Applies to {collidingAnimationTargets.length} animations
|
Applies to {collidingAnimationTargets.length} animations
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<EaseModeToggle mode={mode} onCommit={onCustomEaseCommit} />
|
<EaseModeToggle mode={mode} onCommit={commitEase} />
|
||||||
<span className="sr-only" aria-live="polite">
|
<span className="sr-only" aria-live="polite">
|
||||||
{MODE_LABELS[mode]} ease editor selected
|
{MODE_LABELS[mode]} ease editor selected
|
||||||
</span>
|
</span>
|
||||||
@@ -568,7 +622,7 @@ export function EaseCurveSection({
|
|||||||
springBounce={springBounce}
|
springBounce={springBounce}
|
||||||
wiggleConfig={wiggleConfig}
|
wiggleConfig={wiggleConfig}
|
||||||
tuple={displayTuple}
|
tuple={displayTuple}
|
||||||
onCommit={onCustomEaseCommit}
|
onCommit={commitEase}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Real gsap, not a parseEase stub: this pins the seek behaviour of the `hold`
|
||||||
|
// ease against the engine that actually runs it, and gsap is a studio
|
||||||
|
// dependency. core's own customEase.test.ts covers the resolver in isolation.
|
||||||
|
import { installStudioCustomEase } from "@hyperframes/core/runtime/custom-ease";
|
||||||
|
import { gsap } from "gsap";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("Studio hold ease", () => {
|
||||||
|
it("holds the start value under seek until the destination time", () => {
|
||||||
|
const runtimeGsap = { parseEase: gsap.parseEase.bind(gsap) };
|
||||||
|
expect(installStudioCustomEase(runtimeGsap)).toBe(true);
|
||||||
|
const hold = runtimeGsap.parseEase("hold");
|
||||||
|
expect(hold).toBeTypeOf("function");
|
||||||
|
if (typeof hold !== "function") return;
|
||||||
|
|
||||||
|
const target = { value: 0 };
|
||||||
|
const timeline = gsap.timeline({ paused: true }).to(
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
value: 100,
|
||||||
|
duration: 2,
|
||||||
|
ease: hold,
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
timeline.seek(0.5);
|
||||||
|
expect(target.value).toBe(0);
|
||||||
|
timeline.seek(1);
|
||||||
|
expect(target.value).toBe(0);
|
||||||
|
timeline.seek(1.99);
|
||||||
|
expect(target.value).toBe(0);
|
||||||
|
timeline.seek(2);
|
||||||
|
expect(target.value).toBe(100);
|
||||||
|
timeline.kill();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user