diff --git a/packages/parsers/src/gsapWriter.reviewFixes.test.ts b/packages/parsers/src/gsapWriter.reviewFixes.test.ts index 11ed3c460e..d5531f5f28 100644 --- a/packages/parsers/src/gsapWriter.reviewFixes.test.ts +++ b/packages/parsers/src/gsapWriter.reviewFixes.test.ts @@ -299,6 +299,17 @@ tl.to(".a", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 } }, duration: 1, ea // The original top-level `ease: "none"` is untouched (no second top-level ease). expect((out.match(/ease: "power2.inOut"/g) ?? []).length).toBe(0); }); + + it("round-trips an editor-authored CustomEase through write and reparse", () => { + const customEase = "custom(M0,0 C0.18,0.9 0.32,1.2 1,1)"; + const id = parseGsapScriptAcornForWrite(KF)?.located[0]?.id ?? ""; + const out = updateKeyframeInScript(KF, id, 100, { x: 100 }, customEase); + const keyframe = parseGsapScriptAcornForWrite( + out, + )?.located[0]?.animation.keyframes?.keyframes.find(({ percentage }) => percentage === 100); + + expect(keyframe?.ease).toBe(customEase); + }); }); // ── #8 — convertToKeyframes preserves builtin vars like `delay` ── diff --git a/packages/studio/src/components/editor/AnimationCard.test.tsx b/packages/studio/src/components/editor/AnimationCard.test.tsx index 63562fe734..802e07470d 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,101 @@ 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({ + 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..d178032d68 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({ 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..f25aac0c31 --- /dev/null +++ b/packages/studio/src/components/editor/EaseCurveSection.test.tsx @@ -0,0 +1,348 @@ +// @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"); + expect(host.querySelectorAll('[role="slider"]')).toHaveLength(0); + + 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!.focus(); + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + valueSetter?.call(bounceInput, "0.7"); + bounceInput!.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(onCustomEaseCommit).not.toHaveBeenCalled(); + act(() => bounceInput!.blur()); + 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()); + }); + + it("nudges curve handles with the keyboard", () => { + const { host, root, onCustomEaseCommit } = renderSection("power2.out"); + const firstHandle = host.querySelector('[role="slider"]'); + expect(firstHandle).not.toBeNull(); + + act(() => { + firstHandle!.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }), + ); + }); + + expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.17,1 0.3,1 1,1)"); + act(() => root.unmount()); + }); + + it("closes the preset menu with Escape and returns focus to its trigger", () => { + const { host, root } = renderSection(); + const trigger = host.querySelector("[data-ease-type-dropdown]")!; + openPresetGrid(host); + + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))); + + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(trigger); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/EaseCurveSection.tsx b/packages/studio/src/components/editor/EaseCurveSection.tsx index 262ff6a9f9..015ec6b5a1 100644 --- a/packages/studio/src/components/editor/EaseCurveSection.tsx +++ b/packages/studio/src/components/editor/EaseCurveSection.tsx @@ -1,75 +1,55 @@ -import { useCallback, useRef, useState } from "react"; -import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants"; +import { useEffect, useId, 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 +81,276 @@ 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); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const menuId = useId(); + + useEffect(() => { + if (!open) return; + const menu = menuRef.current; + const selected = + menu?.querySelector('[role="menuitemradio"][aria-checked="true"]') ?? + menu?.querySelector('[role="menuitemradio"]'); + selected?.focus(); + + const closeOutside = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (!menuRef.current?.contains(target) && !triggerRef.current?.contains(target)) { + setOpen(false); + } + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + setOpen(false); + triggerRef.current?.focus(); + }; + document.addEventListener("pointerdown", closeOutside); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("pointerdown", closeOutside); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [open]); + + const handleMenuKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Tab") { + setOpen(false); + return; + } + const offsets: Record = { + ArrowLeft: -1, + ArrowRight: 1, + ArrowUp: -4, + ArrowDown: 4, + }; + const offset = offsets[event.key]; + if (offset === undefined && event.key !== "Home" && event.key !== "End") return; + event.preventDefault(); + const items = Array.from( + event.currentTarget.querySelectorAll('[role="menuitemradio"]'), + ); + if (items.length === 0) return; + const current = Math.max(0, items.indexOf(document.activeElement as HTMLButtonElement)); + const next = + event.key === "Home" + ? 0 + : event.key === "End" + ? items.length - 1 + : (current + (offset ?? 0) + items.length) % items.length; + items[next]?.focus(); + }; + + return ( +
+ + {open && ( + + )} +
+ ); +} + +function resolveEditableCurve(ease: string, springBounce: number | null): Pts | null { + if (springBounce !== null) return DEFAULT_CURVE; + if (ease === "hold") return null; + 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)); +const clampDragY = (py: number) => Math.max(DRAG_VMIN, Math.min(DRAG_VMAX, py)); + +function nudgeCurve(tuple: Pts, handle: "p1" | "p2", key: string, step: number): Pts | null { + const deltaX = key === "ArrowLeft" ? -step : key === "ArrowRight" ? step : 0; + const deltaY = key === "ArrowDown" ? -step : key === "ArrowUp" ? step : 0; + if (deltaX === 0 && deltaY === 0) return null; + const next: Pts = [...tuple]; + const xIndex = handle === "p1" ? 0 : 2; + const yIndex = handle === "p1" ? 1 : 3; + next[xIndex] = round2(Math.max(0, Math.min(1, next[xIndex] + deltaX))); + next[yIndex] = round2(clampDragY(next[yIndex] + deltaY)); + return next; +} -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 +358,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 || ease === "hold"; + const showHandles = curve !== null && !isSpring && !isWiggle; const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => { e.preventDefault(); @@ -190,7 +381,7 @@ export function EaseCurveSection({ const px = Math.max(0, Math.min(1, (sx - PADH) / S)); // py uses the WIDER drag bound (not clampView), so dragging keeps overshoot // fidelity instead of pinning the committed value to the visible view edge. - const py = Math.max(DRAG_VMIN, Math.min(DRAG_VMAX, 1 - (sy - HR) / S)); + const py = clampDragY(1 - (sy - HR) / S); const prev = draft ?? [x1, y1, x2, y2]; const next: Pts = draggingRef.current === "p1" @@ -203,164 +394,180 @@ 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})`); - setDraft(null); + queueMicrotask(() => setDraft(null)); + }; + + const handleKeyDown = (handle: "p1" | "p2", event: React.KeyboardEvent) => { + const next = nudgeCurve(displayTuple, handle, event.key, event.shiftKey ? 0.1 : 0.01); + if (!next) return; + event.preventDefault(); + event.stopPropagation(); + setDraft(next); + onCustomEaseCommit(`custom(M0,0 C${next[0]},${next[1]} ${next[2]},${next[3]} 1,1)`); + queueMicrotask(() => 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))} + + + + {MODE_LABELS[mode]} ease editor selected + + {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)} + onKeyDown={(event) => handleKeyDown(key, event)} + onPointerEnter={() => setHover(key)} + onPointerLeave={() => setHover((h) => (h === key ? null : h))} + /> + + + ))} + +
+ + + ) : ( +

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

+ )}
); } diff --git a/packages/studio/src/components/editor/EaseParamFields.test.tsx b/packages/studio/src/components/editor/EaseParamFields.test.tsx new file mode 100644 index 0000000000..cde5e682e9 --- /dev/null +++ b/packages/studio/src/components/editor/EaseParamFields.test.tsx @@ -0,0 +1,188 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { parseWiggleEase } from "@hyperframes/core/wiggle-ease"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EaseBezierField, SpringBounceField, WiggleField } from "./EaseParamFields"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const roots: Root[] = []; + +afterEach(() => { + act(() => roots.splice(0).forEach((root) => root.unmount())); + document.body.innerHTML = ""; +}); + +function renderField(field: React.ReactNode): HTMLElement { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + roots.push(root); + act(() => root.render(field)); + return host; +} + +function parsedWiggle(ease: string) { + const config = parseWiggleEase(ease); + expect(config).not.toBeNull(); + if (!config) throw new Error(`Expected a valid wiggle ease: ${ease}`); + return config; +} + +function expectWiggleCommit(onCommit: ReturnType, ease: string): void { + expect(onCommit).toHaveBeenLastCalledWith(ease); + const emitted = onCommit.mock.lastCall?.[0]; + expect(typeof emitted === "string" ? parseWiggleEase(emitted) : null).not.toBeNull(); +} + +function inputValue(input: HTMLInputElement, value: string): void { + act(() => { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + valueSetter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +function commitInputValue(input: HTMLInputElement, value: string): void { + act(() => input.focus()); + inputValue(input, value); + act(() => input.blur()); +} + +// Render a default wiggle field, type `value` into the input labelled `label`, +// and return the commit spy — the shared body of the input-edit cases. +function editWiggle(label: string, value: string): ReturnType { + const onCommit = vi.fn(); + const host = renderField( + , + ); + const input = host.querySelector(`[aria-label="${label}"]`); + expect(input).not.toBeNull(); + if (input) commitInputValue(input, value); + return onCommit; +} + +describe("WiggleField", () => { + it("reflects a parsed wiggle config", () => { + const host = renderField( + , + ); + + expect(host.querySelector('[aria-label="Wiggle count"]')?.value).toBe("6"); + expect(host.querySelector('[aria-label="Wiggle type"]')?.value).toBe( + "easeOut", + ); + expect(host.querySelector('[aria-label="Wiggle amplitude"]')?.value).toBe( + "0.26", + ); + }); + + it("commits an edited count", () => { + expectWiggleCommit(editWiggle("Wiggle count", "4"), "wiggle(4,easeOut,0.26)"); + }); + + it("commits an edited type", () => { + const onCommit = vi.fn(); + const host = renderField( + , + ); + + const select = host.querySelector('[aria-label="Wiggle type"]'); + expect(select).not.toBeNull(); + act(() => { + if (!select) return; + select.value = "anticipate"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expectWiggleCommit(onCommit, "wiggle(6,anticipate,0.26)"); + }); + + it("commits an edited amplitude", () => { + expectWiggleCommit(editWiggle("Wiggle amplitude", "0.1"), "wiggle(6,easeOut,0.1)"); + }); + + it("seeds and explicitly commits the per-type default amplitude", () => { + const onCommit = vi.fn(); + const host = renderField( + , + ); + + expect(host.querySelector('[aria-label="Wiggle amplitude"]')?.value).toBe( + "0.08", + ); + const count = host.querySelector('[aria-label="Wiggle count"]'); + expect(count).not.toBeNull(); + if (count) commitInputValue(count, "4"); + + expectWiggleCommit(onCommit, "wiggle(4,easeInOut,0.08)"); + }); + + it("ignores an empty amplitude", () => { + expect(editWiggle("Wiggle amplitude", "")).not.toHaveBeenCalled(); + }); + + it("rejects a count below one", () => { + expect(editWiggle("Wiggle count", "0")).not.toHaveBeenCalled(); + }); +}); + +describe("moved ease parameter fields", () => { + it("keeps the spring bounce commit behavior", () => { + const onCommit = vi.fn(); + const host = renderField(); + const input = host.querySelector('[aria-label="Spring bounce"]'); + expect(input).not.toBeNull(); + if (input) commitInputValue(input, "0.333"); + + expect(onCommit).toHaveBeenLastCalledWith("spring(0.33)"); + }); + + it("commits a numeric draft exactly once when Enter blurs the field", () => { + const onCommit = vi.fn(); + const host = renderField(); + const input = host.querySelector('[aria-label="Spring bounce"]')!; + + act(() => input.focus()); + inputValue(input, "0.7"); + act(() => input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))); + + expect(onCommit).toHaveBeenCalledExactlyOnceWith("spring(0.7)"); + }); + + it("keeps the cubic-bezier tuple commit behavior", () => { + const onCommit = vi.fn(); + const host = renderField(); + const input = host.querySelector( + '[aria-label="Cubic bezier control points"]', + ); + expect(input).not.toBeNull(); + act(() => { + if (!input) return; + input.focus(); + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + valueSetter?.call(input, "0.111, 0.222, 0.777, 0.888"); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + + expect(onCommit).toHaveBeenCalledExactlyOnceWith("custom(M0,0 C0.11,0.22 0.78,0.89 1,1)"); + }); + + it("keeps the bezier input mounted and reports invalid values", () => { + const onCommit = vi.fn(); + const host = renderField(); + const input = host.querySelector( + '[aria-label="Cubic bezier control points"]', + )!; + + commitInputValue(input, "0.5, 1e9, 0.5, -1e9"); + + expect(input.isConnected).toBe(true); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(host.textContent).toContain("Y values must be between -1 and 2"); + expect(onCommit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/components/editor/EaseParamFields.tsx b/packages/studio/src/components/editor/EaseParamFields.tsx new file mode 100644 index 0000000000..d1e783d7e7 --- /dev/null +++ b/packages/studio/src/components/editor/EaseParamFields.tsx @@ -0,0 +1,246 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { parseSpringBounce } from "@hyperframes/core/spring-ease"; +import { + parseWiggleEase, + type WiggleEaseConfig, + type WiggleType, +} from "@hyperframes/core/wiggle-ease"; +import { roundToCenti } from "../../utils/rounding"; +import { MiniCurveSvg } from "./easeCurveSvg"; + +type Pts = [number, number, number, number]; + +const round2 = roundToCenti; +const BEZIER_Y_MIN = -1; +const BEZIER_Y_MAX = 2; +const WIGGLE_TYPES = ["easeOut", "easeInOut", "anticipate", "uniform"] as const; +const WIGGLE_DEFAULT_AMPLITUDE = { + easeOut: 0.16, + easeInOut: 0.08, + anticipate: 0.12, + uniform: 0.14, +} satisfies Record; + +function isWiggleType(value: string): value is WiggleType { + return WIGGLE_TYPES.some((type) => type === value); +} + +function commitWiggle( + onCommit: (ease: string) => void, + count: number, + type: WiggleType, + amplitude: number, +): void { + const ease = `wiggle(${count},${type},${roundToCenti(amplitude)})`; + if (parseWiggleEase(ease)) onCommit(ease); +} + +// Editable cubic-bezier control points, Figma-style ("0.33, 0, 0, 1"). +export function EaseBezierField({ + tuple, + onCommit, +}: { + tuple: Pts; + onCommit: (ease: string) => void; +}) { + const text = tuple.join(", "); + const inputRef = useRef(null); + const errorId = useId(); + const [error, setError] = useState(null); + + useEffect(() => { + if (inputRef.current) inputRef.current.value = text; + setError(null); + }, [text]); + + const commit = (raw: string) => { + const tokens = raw.trim().split(/[\s,]+/); + const nums = tokens.map(Number); + if (tokens.length !== 4 || nums.some((value) => !Number.isFinite(value))) { + setError("Enter four finite numbers"); + return; + } + const [x1, y1, x2, y2] = nums as [number, number, number, number]; + if (y1 < BEZIER_Y_MIN || y1 > BEZIER_Y_MAX || y2 < BEZIER_Y_MIN || y2 > BEZIER_Y_MAX) { + setError(`Y values must be between ${BEZIER_Y_MIN} and ${BEZIER_Y_MAX}`); + return; + } + const cx = (v: number) => Math.max(0, Math.min(1, v)); + setError(null); + onCommit(`custom(M0,0 C${round2(cx(x1))},${round2(y1)} ${round2(cx(x2))},${round2(y2)} 1,1)`); + }; + return ( +
+
+ + setError(null)} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + event.currentTarget.value = text; + setError(null); + } + }} + onBlur={(event) => commit(event.currentTarget.value)} + className={`w-full rounded border bg-black/20 px-1.5 py-1 font-mono text-[10px] text-neutral-300 outline-none ${ + error + ? "border-red-500/70 focus:border-red-400" + : "border-white/10 focus:border-panel-accent/50" + }`} + /> +
+

+ {error ?? "Valid bezier values"} +

+
+ ); +} + +function NumericCommitInput({ + label, + value, + min, + max, + step, + className, + onCommit, +}: { + label: string; + value: number; + min: number; + max?: number; + step: number; + className: string; + onCommit: (value: number) => void; +}) { + const [draft, setDraft] = useState(String(value)); + useEffect(() => setDraft(String(value)), [value]); + const commit = () => { + if (draft.trim() === "") { + setDraft(String(value)); + return; + } + const next = Number(draft); + if (!Number.isFinite(next) || next < min || (max !== undefined && next > max)) { + setDraft(String(value)); + return; + } + onCommit(next); + }; + return ( + setDraft(event.currentTarget.value)} + onBlur={commit} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + setDraft(String(value)); + } + }} + className={className} + /> + ); +} + +export function SpringBounceField({ + springBounce, + onCommit, +}: { + springBounce: number; + onCommit: (ease: string) => void; +}) { + return ( +
+ + { + const bounce = parseSpringBounce(`spring(${value})`); + if (bounce !== null) onCommit(`spring(${round2(bounce)})`); + }} + className="w-16 rounded border border-white/10 bg-black/20 px-1.5 py-1 font-mono text-[10px] text-neutral-300 outline-none focus:border-panel-accent/50" + /> +
+ ); +} + +export function WiggleField({ + config, + onCommit, +}: { + config: WiggleEaseConfig; + onCommit: (ease: string) => void; +}) { + const amplitude = config.amplitude ?? WIGGLE_DEFAULT_AMPLITUDE[config.type]; + return ( +
+
+ + commitWiggle(onCommit, value, config.type, amplitude)} + className="w-14 rounded border border-white/10 bg-black/20 px-1.5 py-1 font-mono text-[10px] text-neutral-300 outline-none focus:border-panel-accent/50" + /> +
+
+ + +
+
+ + commitWiggle(onCommit, config.wiggles, config.type, value)} + className="w-16 rounded border border-white/10 bg-black/20 px-1.5 py-1 font-mono text-[10px] text-neutral-300 outline-none focus:border-panel-accent/50" + /> +
+
+ ); +} diff --git a/packages/studio/src/components/editor/easeCurveSvg.tsx b/packages/studio/src/components/editor/easeCurveSvg.tsx new file mode 100644 index 0000000000..8832404f81 --- /dev/null +++ b/packages/studio/src/components/editor/easeCurveSvg.tsx @@ -0,0 +1,65 @@ +import { evaluateWiggleEase, parseWiggleEase } from "@hyperframes/core/wiggle-ease"; +import { evaluateSpringEase, parseSpringBounce } from "@hyperframes/core/spring-ease"; +import { resolveEaseCurveTuple } from "./gsapAnimationConstants"; + +export function sampledPath( + samples: number, + x: (progress: number) => number, + y: (value: number) => number, + evaluate: (progress: number) => number, +): string { + return Array.from({ length: samples + 1 }, (_, index) => { + const progress = index / samples; + return `${index === 0 ? "M" : "L"}${x(progress)},${y(evaluate(progress))}`; + }).join(" "); +} + +export function holdCurvePath(left: number, bottom: number, right: number, top: number): string { + return `M${left},${bottom} L${right},${bottom} L${right},${top}`; +} + +export function MiniCurveSvg({ + ease, + active, + size = 24, +}: { + ease: string; + active: boolean; + size?: number; +}) { + const springBounce = parseSpringBounce(ease); + const wiggle = parseWiggleEase(ease); + const curve = resolveEaseCurveTuple(ease); + const [x1, y1, x2, y2] = curve; + const s = size; + const p = size / 8; + const g = s - p * 2; + const sx = (px: number) => p + g * px; + const sy = (py: number) => s - p - g * py; + const d = + ease === "hold" + ? holdCurvePath(p, s - p, s - p, p) + : wiggle + ? sampledPath(64, sx, sy, (progress) => + evaluateWiggleEase(progress, wiggle.wiggles, wiggle.type, wiggle.amplitude), + ) + : springBounce !== null + ? sampledPath( + 24, + sx, + (value) => sy(value / 1.2), + (progress) => evaluateSpringEase(progress, springBounce), + ) + : `M${p},${s - p} C${sx(x1)},${sy(y1)} ${sx(x2)},${sy(y2)} ${s - p},${p}`; + return ( + + + + ); +} diff --git a/packages/studio/src/components/editor/easePresetLibrary.test.ts b/packages/studio/src/components/editor/easePresetLibrary.test.ts new file mode 100644 index 0000000000..1d16b66062 --- /dev/null +++ b/packages/studio/src/components/editor/easePresetLibrary.test.ts @@ -0,0 +1,114 @@ +// Imports the parsers SOURCE (not the published subpath) so newly-added eases +// like circ.inOut resolve before the parsers dist is rebuilt. +import { SUPPORTED_EASES } from "../../../../parsers/src/gsapConstants"; +import { parseSpringBounce } from "@hyperframes/core/spring-ease"; +import { parseWiggleEase } from "@hyperframes/core/wiggle-ease"; +import { describe, expect, it } from "vitest"; +import { EASE_PRESETS, easePresetLabel } from "./easePresetLibrary"; +import { resolveEaseCurveTuple } from "./gsapAnimationConstants"; + +const CUSTOM_EASE_PATTERN = + /^custom\(M0,0 C-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)? -?\d+(?:\.\d+)?,-?\d+(?:\.\d+)? 1,1\)$/; + +describe("EASE_PRESETS", () => { + it("contains the complete 32-preset Graphs library with unique fields", () => { + expect(EASE_PRESETS).toHaveLength(32); + expect(new Set(EASE_PRESETS.map(({ id }) => id))).toHaveLength(32); + expect(new Set(EASE_PRESETS.map(({ label }) => label))).toHaveLength(32); + expect(new Set(EASE_PRESETS.map(({ ease }) => ease))).toHaveLength(32); + }); + + it("preserves the required standard, Flow, and Bounce mappings", () => { + const easeByLabel = Object.fromEntries(EASE_PRESETS.map(({ label, ease }) => [label, ease])); + + expect(easeByLabel).toMatchObject({ + Linear: "none", + "Ease In": "power1.in", + "Quad In": "power2.in", + "Cubic In": "power3.in", + "Ease Out": "power1.out", + "Quad Out": "power2.out", + "Cubic Out": "power3.out", + "Ease In & Out": "power1.inOut", + "Quad Ease": "power2.inOut", + "Cubic Ease": "power3.inOut", + "Circular Ease": "circ.inOut", + "Ease In Back": "back.in", + "Ease Out Back": "back.out", + "Flow 1": "wiggle(1,easeInOut,0.20)", + "Flow 2": "wiggle(2,easeInOut,0.15)", + "Flow 3": "wiggle(3,easeInOut,0.12)", + "Flow 4": "wiggle(4,easeInOut,0.10)", + "Flow 5": "wiggle(5,easeInOut,0.08)", + "Flow 6": "wiggle(6,easeInOut,0.07)", + "Flow 7": "wiggle(7,easeInOut,0.06)", + "Bounce 1": "wiggle(4,easeOut,0.22)", + "Bounce 2": "wiggle(6,easeOut,0.26)", + "Bounce 3": "wiggle(9,uniform,0.32)", + "Bounce 4": "wiggle(5,anticipate,0.28)", + Hold: "hold", + }); + + const flows = EASE_PRESETS.filter(({ id }) => id.startsWith("flow-")); + expect(flows.map(({ label }) => label)).toEqual([ + "Flow 1", + "Flow 2", + "Flow 3", + "Flow 4", + "Flow 5", + "Flow 6", + "Flow 7", + ]); + expect(flows.every(({ ease }) => parseWiggleEase(ease) !== null)).toBe(true); + }); + + it("uses supported or valid custom eases that all resolve to finite geometry", () => { + for (const preset of EASE_PRESETS) { + expect( + SUPPORTED_EASES.includes(preset.ease as (typeof SUPPORTED_EASES)[number]) || + CUSTOM_EASE_PATTERN.test(preset.ease) || + parseSpringBounce(preset.ease) !== null || + parseWiggleEase(preset.ease) !== null, + `${preset.label} has unsupported ease ${preset.ease}`, + ).toBe(true); + expect(resolveEaseCurveTuple(preset.ease).every(Number.isFinite)).toBe(true); + } + }); + + it("assigns every preset a valid kind", () => { + const kinds = new Set(["curve", "spring", "wiggle"]); + + for (const preset of EASE_PRESETS) { + expect(kinds.has(preset.kind)).toBe(true); + } + }); + + it("uses valid bounce values for spring presets", () => { + for (const preset of EASE_PRESETS.filter(({ kind }) => kind === "spring")) { + const bounce = parseSpringBounce(preset.ease); + + expect(bounce).not.toBeNull(); + expect(bounce).toBeGreaterThanOrEqual(0); + expect(bounce).toBeLessThanOrEqual(1); + } + }); + + it("uses parseable eases for wiggle presets", () => { + for (const preset of EASE_PRESETS.filter(({ kind }) => kind === "wiggle")) { + expect(parseWiggleEase(preset.ease)).not.toBeNull(); + } + }); + + it("preserves stable preset ids", () => { + for (const id of ["flow-7", "hold", "rebound-in"]) { + expect(EASE_PRESETS.some((preset) => preset.id === id)).toBe(true); + } + }); + + it("resolves preset labels by exact ease", () => { + expect(easePresetLabel("spring(0.6)")).toBe("Bouncy"); + expect(easePresetLabel("back.in")).toBe("Ease In Back"); + expect(easePresetLabel("wiggle(9,uniform,0.32)")).toBe("Bounce 3"); + expect(easePresetLabel("custom(x)")).toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/easePresetLibrary.ts b/packages/studio/src/components/editor/easePresetLibrary.ts new file mode 100644 index 0000000000..37d9b449d8 --- /dev/null +++ b/packages/studio/src/components/editor/easePresetLibrary.ts @@ -0,0 +1,49 @@ +export const EASE_PRESETS = [ + { id: "linear", label: "Linear", ease: "none", kind: "curve" }, + { id: "ease-in", label: "Ease In", ease: "power1.in", kind: "curve" }, + { id: "quad-in", label: "Quad In", ease: "power2.in", kind: "curve" }, + { id: "cubic-in", label: "Cubic In", ease: "power3.in", kind: "curve" }, + { id: "ease-out", label: "Ease Out", ease: "power1.out", kind: "curve" }, + { id: "quad-out", label: "Quad Out", ease: "power2.out", kind: "curve" }, + { id: "cubic-out", label: "Cubic Out", ease: "power3.out", kind: "curve" }, + { id: "ease", label: "Ease In & Out", ease: "power1.inOut", kind: "curve" }, + { id: "quad-ease", label: "Quad Ease", ease: "power2.inOut", kind: "curve" }, + { id: "cubic-ease", label: "Cubic Ease", ease: "power3.inOut", kind: "curve" }, + { id: "circular-ease", label: "Circular Ease", ease: "circ.inOut", kind: "curve" }, + { id: "rebound-in", label: "Ease In Back", ease: "back.in", kind: "curve" }, + { id: "rebound-out", label: "Ease Out Back", ease: "back.out", kind: "curve" }, + { id: "flow-1", label: "Flow 1", ease: "wiggle(1,easeInOut,0.20)", kind: "wiggle" }, + { id: "flow-2", label: "Flow 2", ease: "wiggle(2,easeInOut,0.15)", kind: "wiggle" }, + { id: "flow-3", label: "Flow 3", ease: "wiggle(3,easeInOut,0.12)", kind: "wiggle" }, + { id: "flow-4", label: "Flow 4", ease: "wiggle(4,easeInOut,0.10)", kind: "wiggle" }, + { id: "flow-5", label: "Flow 5", ease: "wiggle(5,easeInOut,0.08)", kind: "wiggle" }, + { id: "flow-6", label: "Flow 6", ease: "wiggle(6,easeInOut,0.07)", kind: "wiggle" }, + { id: "flow-7", label: "Flow 7", ease: "wiggle(7,easeInOut,0.06)", kind: "wiggle" }, + { id: "bounce-1", label: "Bounce 1", ease: "wiggle(4,easeOut,0.22)", kind: "wiggle" }, + { id: "bounce-2", label: "Bounce 2", ease: "wiggle(6,easeOut,0.26)", kind: "wiggle" }, + { id: "bounce-3", label: "Bounce 3", ease: "wiggle(9,uniform,0.32)", kind: "wiggle" }, + { id: "bounce-4", label: "Bounce 4", ease: "wiggle(5,anticipate,0.28)", kind: "wiggle" }, + { id: "hold", label: "Hold", ease: "hold", kind: "curve" }, + { + id: "rebound-ease", + label: "Ease In & Out Back", + ease: "back.inOut", + kind: "curve", + }, + { id: "expo-in", label: "Expo In", ease: "expo.in", kind: "curve" }, + { id: "expo-out", label: "Expo Out", ease: "expo.out", kind: "curve" }, + // Runtime spring(bounce) approximates Figma stiffness and damping with bounce alone. + { id: "spring-gentle", label: "Gentle", ease: "spring(0.15)", kind: "spring" }, + { id: "spring-quick", label: "Quick", ease: "spring(0.4)", kind: "spring" }, + { id: "spring-bouncy", label: "Bouncy", ease: "spring(0.6)", kind: "spring" }, + { id: "spring-slow", label: "Slow", ease: "spring(0.25)", kind: "spring" }, +] as const satisfies ReadonlyArray<{ + id: string; + label: string; + ease: string; + kind: "curve" | "spring" | "wiggle"; +}>; + +export function easePresetLabel(ease: string): string | null { + return EASE_PRESETS.find((preset) => preset.ease === ease)?.label ?? null; +} diff --git a/packages/studio/src/components/editor/gsapAnimationConstants.ts b/packages/studio/src/components/editor/gsapAnimationConstants.ts index 0c97947cf2..5fa44e9006 100644 --- a/packages/studio/src/components/editor/gsapAnimationConstants.ts +++ b/packages/studio/src/components/editor/gsapAnimationConstants.ts @@ -1,4 +1,4 @@ -import { controlPointsForGsapEase } from "./studioMotion"; +import { controlPointsForGsapEase, parseStudioCustomEaseData } from "./studioMotion"; export const METHOD_LABELS: Record = { set: "Set", @@ -116,9 +116,14 @@ export const EASE_CURVES: Record = { "back.out": [0.34, 1.56, 0.64, 1], "back.in": [0.36, 0, 0.66, -0.56], "back.inOut": [0.68, -0.55, 0.27, 1.55], + "circ.inOut": [0.785, 0.135, 0.15, 0.86], "expo.out": [0.16, 1, 0.3, 1], "expo.in": [0.7, 0, 0.84, 0], "expo.inOut": [0.87, 0, 0.13, 1], + "bounce.out": [0.34, 1.56, 0.64, 0.74], + "bounce.in": [0.36, 0.26, 0.66, -0.56], + "elastic.out(1,0.3)": [0.16, 1.45, 0.28, 0.82], + "elastic.inOut(1,0.3)": [0.68, -0.55, 0.32, 1.55], // After Effects polarity: "in" eases into the keyframe (slow END, CP2 y=1), // "out" eases out of it (slow START, CP1 y=0). Matches the "(AE)" labels. "ae-ease": [0.333, 0, 0.667, 1], @@ -126,18 +131,15 @@ export const EASE_CURVES: Record = { "ae-ease-out": [0.333, 0, 0.667, 0.667], }; -export function parseCustomEaseFromString(ease: string): { - x1: number; - y1: number; - x2: number; - y2: number; -} { - const match = ease.match(/^custom\((.+)\)$/); - if (!match) return controlPointsForGsapEase("power2.out"); - const data = match[1]; - const nums = data.match(/[\d.]+/g)?.map(Number); - if (!nums || nums.length < 6) return controlPointsForGsapEase("power2.out"); - return { x1: nums[2], y1: nums[3], x2: nums[4], y2: nums[5] }; +export function resolveEaseCurveTuple(ease: string): [number, number, number, number] { + if (ease.startsWith("custom(")) { + const points = parseStudioCustomEaseData(ease.match(/^custom\((.+)\)$/)?.[1]); + if (points) return [points.x1, points.y1, points.x2, points.y2]; + } + const curve = EASE_CURVES[ease]; + if (curve) return curve; + const points = controlPointsForGsapEase(ease); + return [points.x1, points.y1, points.x2, points.y2]; } export const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]); diff --git a/packages/studio/src/telemetry/events.test.ts b/packages/studio/src/telemetry/events.test.ts index 03d706e02e..ad4e7f1401 100644 --- a/packages/studio/src/telemetry/events.test.ts +++ b/packages/studio/src/telemetry/events.test.ts @@ -12,6 +12,7 @@ const { trackStudioRenderStart, trackStudioRazorSplit, trackStudioExpandedClipEdit, + trackStudioSegmentEaseEdit, trackStudioFeedback, } = await import("./events"); @@ -71,6 +72,14 @@ describe("studio telemetry events", () => { expect(trackEvent).toHaveBeenCalledWith("studio_expanded_clip_edit", { action: "resize" }); }); + it("trackStudioSegmentEaseEdit emits 'studio_segment_ease_edit' with action and ease", () => { + trackStudioSegmentEaseEdit({ ease: "power2.out" }); + expect(trackEvent).toHaveBeenCalledWith("studio_segment_ease_edit", { + action: "commit", + ease: "power2.out", + }); + }); + it.each([0, 10])("trackStudioFeedback preserves NPS boundary %i and its scale", (rating) => { trackStudioFeedback({ rating }); diff --git a/packages/studio/src/telemetry/events.ts b/packages/studio/src/telemetry/events.ts index f8ab262473..b73d008029 100644 --- a/packages/studio/src/telemetry/events.ts +++ b/packages/studio/src/telemetry/events.ts @@ -63,6 +63,11 @@ export function trackStudioExpandedClipEdit(props: { trackEvent("studio_expanded_clip_edit", { action: props.action }); } +// Adoption signal for committing an edit to a segment's ease. +export function trackStudioSegmentEaseEdit(props: { ease: string }): void { + trackEvent("studio_segment_ease_edit", { action: "commit", ease: props.ease }); +} + export function trackStudioFeedback(props: { rating: number; comment?: string }): void { trackEvent("survey sent", { $survey_id: "studio_experience",