diff --git a/packages/studio/src/components/editor/AnimationCard.test.tsx b/packages/studio/src/components/editor/AnimationCard.test.tsx index 63562fe734..42f93254f7 100644 --- a/packages/studio/src/components/editor/AnimationCard.test.tsx +++ b/packages/studio/src/components/editor/AnimationCard.test.tsx @@ -5,11 +5,16 @@ import { createRoot } from "react-dom/client"; 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"; + +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; afterEach(() => { document.body.innerHTML = ""; + trackStudioSegmentEaseEdit.mockClear(); }); function baseAnimation(overrides: Partial = {}): GsapAnimation { @@ -26,6 +31,102 @@ function baseAnimation(overrides: Partial = {}): GsapAnimation { const noop = () => {}; +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()); + + const preset = host.querySelector(`[data-ease-preset-id="${presetId}"]`); + expect(preset).not.toBeNull(); + act(() => preset?.click()); + return presetConfig.ease; +} + +function renderExpandedCard({ + animation, + flat, + onUpdateMeta = vi.fn(), + onUpdateKeyframeEase = vi.fn(), +}: { + animation: GsapAnimation; + flat?: boolean; + onUpdateMeta?: ReturnType; + onUpdateKeyframeEase?: ReturnType; +}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + return { host, root }; +} + +describe("AnimationCard ease editing", () => { + it("commits one preset change to the selected keyframe segment", () => { + const onUpdateKeyframeEase = vi.fn(); + const animation = baseAnimation({ + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 50, properties: { opacity: 0.5 } }, + { percentage: 100, properties: { opacity: 1 } }, + ], + }, + }); + const view = renderExpandedCard({ animation, onUpdateKeyframeEase }); + + const segment = Array.from(view.host.querySelectorAll("button")).find((button) => + button.textContent?.includes("0% → 50%"), + ); + expect(segment).toBeDefined(); + act(() => segment?.click()); + const ease = selectPreset(view.host, "quad-out"); + + expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease); + expect(trackStudioSegmentEaseEdit).toHaveBeenCalledExactlyOnceWith({ + action: "commit", + ease, + }); + act(() => view.root.unmount()); + }); + + it("commits one preset change through flat tween metadata", () => { + const onUpdateMeta = vi.fn(); + const onUpdateKeyframeEase = vi.fn(); + const animation = baseAnimation({ id: "flat-tween" }); + const view = renderExpandedCard({ + animation, + flat: true, + onUpdateMeta, + onUpdateKeyframeEase, + }); + + const ease = selectPreset(view.host, "quad-out"); + + expect(onUpdateMeta).toHaveBeenCalledExactlyOnceWith(animation.id, { ease }); + expect(onUpdateKeyframeEase).not.toHaveBeenCalled(); + expect(trackStudioSegmentEaseEdit).not.toHaveBeenCalled(); + act(() => view.root.unmount()); + }); +}); + describe("AnimationCard flat branch", () => { it("renders a mint border-left and panel-token colors when flat", () => { const host = document.createElement("div"); diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 97f8efa404..214d5b013d 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, useMemo, useState } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants"; +import { trackStudioSegmentEaseEdit } from "../../telemetry/events"; import { RESPONSIVE_GRID } from "./propertyPanelHelpers"; import { MetricField, SelectField } from "./propertyPanelPrimitives"; import { controlPointsForGsapEase } from "./studioMotion"; @@ -264,7 +265,10 @@ export const AnimationCard = memo(function AnimationCard({ globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"} expandedPct={expandedKfPct} onToggle={setExpandedKfPct} - onEaseCommit={(pct, ease) => onUpdateKeyframeEase(animation.id, pct, ease)} + onEaseCommit={(pct, ease) => { + onUpdateKeyframeEase(animation.id, pct, ease); + trackStudioSegmentEaseEdit({ action: "commit", ease }); + }} onApplyAll={ onSetAllKeyframeEases ? (ease) => onSetAllKeyframeEases(animation.id, ease) @@ -292,7 +296,6 @@ export const AnimationCard = memo(function AnimationCard({ /> { const easeKey = animation.keyframes ? "easeEach" : "ease"; onUpdateMeta(animation.id, { [easeKey]: customEase }); diff --git a/packages/studio/src/components/editor/EaseCurveSection.test.tsx b/packages/studio/src/components/editor/EaseCurveSection.test.tsx new file mode 100644 index 0000000000..d72e9e85ac --- /dev/null +++ b/packages/studio/src/components/editor/EaseCurveSection.test.tsx @@ -0,0 +1,315 @@ +// @vitest-environment happy-dom + +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"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function renderSection(ease = "none", onCustomEaseCommit = vi.fn()) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + return { host, root, onCustomEaseCommit }; +} + +// The preset grid now lives behind the Figma-style ease-type dropdown; open it +// before querying preset tiles. +function openPresetGrid(host: HTMLElement): void { + const dropdown = host.querySelector("[data-ease-type-dropdown]"); + act(() => dropdown!.click()); +} + +function presetPath(host: HTMLElement, id: string): string | null | undefined { + return host + .querySelector(`[data-ease-preset-id="${id}"]`) + ?.querySelector("path") + ?.getAttribute("d"); +} + +function countPathExtrema(path: string): number { + const values = Array.from(path.matchAll(/[ML][^,]+,([^ ]+)/g), (match) => Number(match[1])); + const directions = values + .slice(1) + .map((value, index) => Math.sign(value - values[index]!)) + .filter((direction) => direction !== 0); + return directions.filter((direction, index) => index > 0 && direction !== directions[index - 1]) + .length; +} + +function renderStatefulSection(initialEase = "none", onCustomEaseCommit = vi.fn()) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const Harness = () => { + const [ease, setEase] = useState(initialEase); + return ( + { + onCustomEaseCommit(nextEase); + setEase(nextEase); + }} + /> + ); + }; + act(() => root.render()); + return { host, root, onCustomEaseCommit }; +} + +function clickMode(host: HTMLElement, mode: "curve" | "spring" | "wiggle"): void { + const toggle = host.querySelector(`[data-ease-mode="${mode}"]`); + expect(toggle).not.toBeNull(); + act(() => toggle!.click()); +} + +function presetIds(host: HTMLElement): string[] { + return Array.from(host.querySelectorAll("[data-ease-preset-id]"), (preset) => + preset.getAttribute("data-ease-preset-id"), + ).filter((id): id is string => id !== null); +} + +function editorLabel(host: HTMLElement): string | null { + return host.querySelector("[data-ease-type-dropdown] span")?.textContent ?? null; +} + +describe("EaseCurveSection preset grid", () => { + it.each([ + ["curve", "none", "linear", ["flow-7", "spring-bouncy"]], + ["spring", "spring(0.42)", "spring-bouncy", ["linear", "flow-7"]], + ["wiggle", "wiggle(3,easeInOut,0.12)", "flow-7", ["linear", "spring-bouncy"]], + ] as const)("shows only %s presets", (_mode, ease, includedPreset, excludedPresets) => { + const { host, root } = renderSection(ease); + openPresetGrid(host); + const ids = presetIds(host); + + expect(ids).toContain(includedPreset); + for (const id of excludedPresets) expect(ids).not.toContain(id); + + act(() => root.unmount()); + }); + + it("renders a wiggle graph and fields without curve handles or fallback copy", () => { + const { host, root } = renderSection("wiggle(3,easeInOut,0.12)"); + const graph = host.querySelector('svg[viewBox="0 0 216 288"]'); + + expect(graph?.querySelector("path")).not.toBeNull(); + expect(graph?.querySelectorAll(".cursor-grab")).toHaveLength(0); + expect(host.querySelector('[aria-label="Wiggle count"]')).not.toBeNull(); + expect(host.querySelector('[aria-label="Wiggle type"]')).not.toBeNull(); + expect(host.querySelector('[aria-label="Wiggle amplitude"]')).not.toBeNull(); + expect(host.textContent).not.toContain("switch to"); + + act(() => root.unmount()); + }); + + it.each([ + ["spring(0.6)", "Bouncy"], + ["spring(0.37)", "Custom spring"], + ["wiggle(2,uniform,0.3)", "Custom wiggle"], + ["custom(M0,0 C0.1,0.2 0.8,0.9 1,1)", "Custom bezier"], + ])("labels %s as %s", (ease, expectedLabel) => { + const { host, root } = renderSection(ease); + + expect(editorLabel(host)).toBe(expectedLabel); + + act(() => root.unmount()); + }); + + it("commits the selected preset through the existing custom-ease callback", () => { + const { host, root, onCustomEaseCommit } = renderSection("wiggle(3,easeInOut,0.12)"); + openPresetGrid(host); + const tile = host.querySelector('[data-ease-preset-id="flow-7"]'); + expect(tile).not.toBeNull(); + + act(() => tile!.click()); + + expect(onCustomEaseCommit).toHaveBeenCalledTimes(1); + expect(onCustomEaseCommit).toHaveBeenCalledWith("wiggle(7,easeInOut,0.06)"); + act(() => root.unmount()); + }); + + it("commits Hold as a segment ease", () => { + const { host, root, onCustomEaseCommit } = renderSection(); + openPresetGrid(host); + const tile = host.querySelector('[data-ease-preset-id="hold"]'); + expect(tile).not.toBeNull(); + + act(() => tile!.click()); + + expect(onCustomEaseCommit).toHaveBeenCalledWith("hold"); + act(() => root.unmount()); + }); + + it("draws Hold as a flat step with an end jump", () => { + const { host, root } = renderSection(); + openPresetGrid(host); + const path = presetPath(host, "hold"); + + expect(path).toBe("M3,21 L21,21 L21,3"); + + act(() => root.unmount()); + }); + + it("draws Hold as the same flat step in the big editor graph", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + + const path = host + .querySelector('svg[viewBox="0 0 216 288"]') + ?.querySelector("path") + ?.getAttribute("d"); + expect(path).toBe("M16,236 L200,236 L200,52"); + + act(() => root.unmount()); + }); + + it("preserves negative custom-ease control points in both curve graphs", () => { + const ease = "custom(M0,0 C0.3,-0.5 0.7,1.5 1,1)"; + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + <> + + + , + ); + }); + + const miniPath = host + .querySelector('svg[viewBox="0 0 24 24"]') + ?.querySelector("path") + ?.getAttribute("d"); + const editorPath = host + .querySelector('svg[viewBox="0 0 216 288"]') + ?.querySelector("path") + ?.getAttribute("d"); + expect(miniPath).toBe("M3,21 C8.399999999999999,30 15.6,-6 21,3"); + expect(editorPath).toBe("M16,236 C71.19999999999999,328 144.79999999999998,-40 200,52"); + + act(() => root.unmount()); + }); + + it("draws wiggle presets as sampled oscillating glyphs", () => { + const { host, root } = renderSection("wiggle(3,easeInOut,0.12)"); + openPresetGrid(host); + const flow = presetPath(host, "flow-7"); + const bounce = presetPath(host, "bounce-3"); + + expect(flow).toMatch(/^M.* L/); + expect(bounce).toMatch(/^M.* L/); + expect(flow).not.toBe(bounce); + expect(countPathExtrema(flow!)).toBeGreaterThan(8); + expect(countPathExtrema(bounce!)).toBeGreaterThan(8); + + act(() => root.unmount()); + }); + + it("draws Flow with increasing-frequency sampled glyphs", () => { + const { host, root } = renderSection("wiggle(3,easeInOut,0.12)"); + openPresetGrid(host); + const flow1 = presetPath(host, "flow-1"); + const flow7 = presetPath(host, "flow-7"); + + expect(flow1).toMatch(/^M.* L/); + expect(flow7).toMatch(/^M.* L/); + expect(flow1).not.toBe(flow7); + + act(() => root.unmount()); + }); + + it("draws explicit wiggle amplitudes in sampled glyphs", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => + root.render( + <> + + + , + ), + ); + const paths = Array.from(host.querySelectorAll("path"), (path) => path.getAttribute("d")); + + expect(paths[0]).not.toBe(paths[1]); + + act(() => root.unmount()); + }); + + it("commits each mode default when switching modes", () => { + const { host, root, onCustomEaseCommit } = renderStatefulSection(); + + clickMode(host, "spring"); + expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.42)"); + + clickMode(host, "curve"); + expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.16,1 0.3,1 1,1)"); + + clickMode(host, "wiggle"); + expect(onCustomEaseCommit).toHaveBeenLastCalledWith("wiggle(3,easeInOut,0.12)"); + + act(() => root.unmount()); + }); + + it("keeps spring bounce editing wired to the custom-ease callback", () => { + const { host, root, onCustomEaseCommit } = renderStatefulSection("spring(0.37)"); + const bounceInput = host.querySelector('[aria-label="Spring bounce"]'); + expect(bounceInput).not.toBeNull(); + + act(() => { + bounceInput!.value = "0.7"; + bounceInput!.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.7)"); + + act(() => root.unmount()); + }); + + it("keeps curve handles draggable and commits the edited custom curve", () => { + const { host, root, onCustomEaseCommit } = renderSection("power2.out"); + const graph = host.querySelector('svg[viewBox="0 0 216 288"]'); + const handle = graph?.querySelector(".cursor-grab"); + expect(graph).not.toBeNull(); + expect(handle).not.toBeNull(); + vi.spyOn(graph!, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 216, 288)); + handle!.setPointerCapture = vi.fn(); + + act(() => { + handle!.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 46, clientY: 52 }), + ); + }); + act(() => { + graph!.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + pointerId: 1, + clientX: 108, + clientY: 144, + }), + ); + }); + act(() => { + graph!.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 })); + }); + + expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.5,0.5 0.3,1 1,1)"); + + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/EaseCurveSection.tsx b/packages/studio/src/components/editor/EaseCurveSection.tsx index 262ff6a9f9..f823d4fe40 100644 --- a/packages/studio/src/components/editor/EaseCurveSection.tsx +++ b/packages/studio/src/components/editor/EaseCurveSection.tsx @@ -1,75 +1,52 @@ -import { useCallback, useRef, useState } from "react"; -import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants"; +import { useEffect, useRef, useState } from "react"; +import { evaluateSpringEase, parseSpringBounce } from "@hyperframes/core/spring-ease"; +import { + evaluateWiggleEase, + parseWiggleEase, + type WiggleEaseConfig, +} from "@hyperframes/core/wiggle-ease"; +import { EASE_PRESETS, easePresetLabel } from "./easePresetLibrary"; +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"; -// Figma-canonical ordering: linear, the three core eases, then the expressive -// (back / snappy) family. Each maps to a GSAP ease so it round-trips cleanly. -const PRESET_GRID_EASES = [ - "none", - "power2.in", - "power2.out", - "power2.inOut", - "back.in", - "back.out", - "back.inOut", - "expo.out", -] as const; +export { MiniCurveSvg } from "./easeCurveSvg"; -function MiniCurveSvg({ - curve, - active, -}: { - curve: [number, number, number, number]; - active: boolean; -}) { - const [x1, y1, x2, y2] = curve; - const s = 24; - const p = 3; - const g = s - p * 2; - const sx = (px: number) => p + g * px; - const sy = (py: number) => s - p - g * py; - const d = `M${p},${s - p} C${sx(x1)},${sy(y1)} ${sx(x2)},${sy(y2)} ${s - p},${p}`; - return ( - - - - ); -} +const EASE_MODES = ["curve", "spring", "wiggle"] as const; +type EaseMode = (typeof EASE_MODES)[number]; const EasePresetGrid = function EasePresetGrid({ + kind, currentEase, onSelect, }: { + kind: EaseMode; currentEase: string; onSelect: (ease: string) => void; }) { return ( -
- {PRESET_GRID_EASES.map((name) => { - const curve = EASE_CURVES[name]; - if (!curve) return null; - const isActive = currentEase === name; +
+ {EASE_PRESETS.filter((preset) => preset.kind === kind).map((preset) => { + const isActive = currentEase === preset.ease; return ( ); @@ -101,56 +78,191 @@ const DRAG_VMIN = -1; const ACCENT = "#3CE6AC"; type Pts = [number, number, number, number]; +const DEFAULT_CURVE: Pts = EASE_CURVES["power2.out"]; +const MODE_LABELS = { curve: "Curve", spring: "Spring", wiggle: "Wiggle" } satisfies Record< + EaseMode, + string +>; +const DEFAULT_EASE_BY_MODE = { + curve: `custom(M0,0 C${DEFAULT_CURVE[0]},${DEFAULT_CURVE[1]} ${DEFAULT_CURVE[2]},${DEFAULT_CURVE[3]} 1,1)`, + spring: "spring(0.42)", + wiggle: "wiggle(3,easeInOut,0.12)", +} satisfies Record; + +function EaseModeToggle({ mode, onCommit }: { mode: EaseMode; onCommit: (ease: string) => void }) { + return ( +
+ {EASE_MODES.map((candidateMode) => { + const active = candidateMode === mode; + return ( + + ); + })} +
+ ); +} + +// Figma-style ease-type dropdown: the current ease (glyph + name) as a button +// that opens the preset grid in a popover. This is where a preset is selected — +// the grid is no longer shown inline. +function EaseTypeDropdown({ + kind, + ease, + label, + onSelect, +}: { + kind: EaseMode; + ease: string; + label: string; + onSelect: (ease: string) => void; +}) { + const [open, setOpen] = useState(false); + return ( +
+ + {open && ( +
+ { + onSelect(next); + setOpen(false); + }} + /> +
+ )} +
+ ); +} + +function resolveEditableCurve(ease: string, springBounce: number | null): Pts | null { + if (springBounce !== null) return DEFAULT_CURVE; + if (ease === "hold") return DEFAULT_CURVE; + if (ease.startsWith("custom(") || ease in EASE_CURVES) return resolveEaseCurveTuple(ease); + return null; +} + +function resolveEditorLabel(ease: string, springBounce: number | null, isWiggle: boolean): string { + const presetLabel = easePresetLabel(ease); + if (presetLabel !== null) return presetLabel; + if (springBounce !== null) return "Custom spring"; + if (isWiggle) return "Custom wiggle"; + if (ease.startsWith("custom(")) return "Custom bezier"; + return EASE_LABELS[ease] ?? ease; +} const xToSvg = (px: number) => PADH + S * px; const yToSvg = (py: number) => HR + S * (1 - py); const clampView = (py: number) => Math.max(VMIN, Math.min(VMAX, py)); -function cubicAt(t: number, c0: number, c1: number, c2: number, c3: number): number { - const mt = 1 - t; - return mt * mt * mt * c0 + 3 * mt * mt * t * c1 + 3 * mt * t * t * c2 + t * t * t * c3; +function curvePathFor( + ease: string, + springBounce: number | null, + wiggleConfig: WiggleEaseConfig | null, + tuple: Pts, +): string { + if (ease === "hold") return holdCurvePath(xToSvg(0), yToSvg(0), xToSvg(1), yToSvg(1)); + if (wiggleConfig !== null) { + return sampledPath(64, xToSvg, yToSvg, (progress) => + evaluateWiggleEase(progress, wiggleConfig.wiggles, wiggleConfig.type, wiggleConfig.amplitude), + ); + } + if (springBounce !== null) { + return sampledPath(64, xToSvg, yToSvg, (progress) => + evaluateSpringEase(progress, springBounce), + ); + } + const [x1, y1, x2, y2] = tuple; + return `M${xToSvg(0)},${yToSvg(0)} C${xToSvg(x1)},${yToSvg(y1)} ${xToSvg(x2)},${yToSvg(y2)} ${xToSvg(1)},${yToSvg(1)}`; +} + +function EaseParameterField({ + springBounce, + wiggleConfig, + tuple, + onCommit, +}: { + springBounce: number | null; + wiggleConfig: WiggleEaseConfig | null; + tuple: Pts; + onCommit: (ease: string) => void; +}) { + if (springBounce !== null) { + return ; + } + if (wiggleConfig !== null) return ; + return ; } export function EaseCurveSection({ ease, - duration, onCustomEaseCommit, }: { ease: string; - duration?: number; onCustomEaseCommit: (ease: string) => void; }) { - const isCustom = ease.startsWith("custom("); - const curveFromPreset = EASE_CURVES[ease]; - const customPoints = isCustom ? parseCustomEaseFromString(ease) : null; - const curve: Pts | null = - isCustom && customPoints - ? [customPoints.x1, customPoints.y1, customPoints.x2, customPoints.y2] - : (curveFromPreset ?? null); + const springBounce = parseSpringBounce(ease); + const isSpring = springBounce !== null; + const wiggleConfig = parseWiggleEase(ease); + const isWiggle = wiggleConfig !== null; + const mode: EaseMode = isSpring ? "spring" : isWiggle ? "wiggle" : "curve"; + const curve = resolveEditableCurve(ease, springBounce); const [draft, setDraft] = useState(null); - const [progress, setProgress] = useState(null); const [hover, setHover] = useState<"p1" | "p2" | null>(null); const draggingRef = useRef<"p1" | "p2" | null>(null); const svgRef = useRef(null); - const rafRef = useRef(0); - const play = useCallback(() => { - const start = performance.now(); - const dur = 1100; - const tick = (now: number) => { - const t = Math.min((now - start) / dur, 1); - setProgress(t); - if (t < 1) rafRef.current = requestAnimationFrame(tick); - else setTimeout(() => setProgress(null), 450); - }; - cancelAnimationFrame(rafRef.current); - rafRef.current = requestAnimationFrame(tick); - }, []); + // Keep the local draft displayed until the committed `ease` prop round-trips + // back (write → reparse → re-render), then drop it. Clearing on pointer-up + // instead would fall back to the STALE prop for a frame — the curve snaps to + // the old value and jumps to the new one (the commit flicker). By the time + // `ease` changes, `curve` already equals the draft, so the handoff is seamless. + useEffect(() => { + setDraft(null); + }, [ease]); - const active = draft ?? curve; - if (!active) return null; - const [x1, y1, x2, y2] = active; + const activeTuple = draft ?? curve; + const displayTuple = activeTuple ?? DEFAULT_CURVE; + const [x1, y1, x2, y2] = displayTuple; // Anchors + control handles. Handle *display* is clamped to the view so an // extreme loaded overshoot rides the edge instead of disappearing. @@ -158,18 +270,9 @@ 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)) }; - // Curve drawn from the true control points (so its shape is exact). - const cp1 = { x: xToSvg(x1), y: yToSvg(y1) }; - const cp2 = { x: xToSvg(x2), y: yToSvg(y2) }; - const curvePath = `M${a0.x},${a0.y} C${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${a1.x},${a1.y}`; - - let dot: { x: number; y: number } | null = null; - if (progress !== null) { - dot = { - x: xToSvg(cubicAt(progress, 0, x1, x2, 1)), - y: yToSvg(cubicAt(progress, 0, y1, y2, 1)), - }; - } + const curvePath = curvePathFor(ease, springBounce, wiggleConfig, displayTuple); + const showGraph = activeTuple !== null || isWiggle; + const showHandles = !isSpring && !isWiggle; const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => { e.preventDefault(); @@ -203,164 +306,158 @@ 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`; + // 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. onCustomEaseCommit(`custom(${path})`); - setDraft(null); }; const top = yToSvg(1); const bottom = yToSvg(0); const left = xToSvg(0); const right = xToSvg(1); - const label = isCustom ? "Custom curve" : (EASE_LABELS[ease] ?? ease); - const bezierText = `${x1} · ${y1} · ${x2} · ${y2}`; + const label = resolveEditorLabel(ease, springBounce, isWiggle); return (
- onCustomEaseCommit(name)} /> -
- Speed curve - -
-
- - {/* Grid — quarter lines inside the unit square */} - {[0.25, 0.5, 0.75].map((q) => ( - - ))} - {[0.25, 0.5, 0.75].map((q) => ( - - ))} - {/* Unit-square frame (progress 0 → 1) */} - - {/* Linear reference diagonal */} - - {/* Tangent handle lines */} - - - {/* The curve */} - - {/* Anchors at (0,0) and (1,1) */} - - - {/* Animated preview dot */} - {dot && ( - <> - - - - )} - {/* Draggable control handles (large transparent hit area + visible dot) */} - {[["p1", p1] as const, ["p2", p2] as const].map(([key, pt]) => ( - - handlePointerDown(key, e)} - onPointerEnter={() => setHover(key)} - onPointerLeave={() => setHover((h) => (h === key ? null : h))} + + + {showGraph ? ( + <> +
+ + {/* Grid — quarter lines inside the unit square */} + {[0.25, 0.5, 0.75].map((q) => ( + + ))} + {[0.25, 0.5, 0.75].map((q) => ( + + ))} + {/* Unit-square frame (progress 0 → 1) */} + + {/* Linear reference diagonal */} + - + + + + )} + {/* The curve */} + - - ))} - -
- {/* Axis + value readout */} -
- {duration != null && duration > 0 ? "0s" : "start"} - time → - {duration != null && duration > 0 ? `${duration}s` : "end"} -
-
- {label} - - {bezierText} - -
+ {/* Anchors at (0,0) and (1,1) */} + + + {/* Draggable control handles (large transparent hit area + visible dot) */} + {showHandles && + [["p1", p1] as const, ["p2", p2] as const].map(([key, pt]) => ( + + handlePointerDown(key, e)} + onPointerEnter={() => setHover(key)} + onPointerLeave={() => setHover((h) => (h === key ? null : h))} + /> + + + ))} + +
+ + + ) : ( +

+ {label} preset: switch to Curve, Spring, or Wiggle above to shape it by hand. +

+ )}
); }