@hyperframes/studio 0.7.70 → 0.7.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-DpKeYLEo.js → hyperframes-player-Bjf2HPzR.js} +1 -1
- package/dist/assets/index-BgZMle9I.css +1 -0
- package/dist/assets/{index-DXjAllII.js → index-Ch1hbJ3e.js} +1 -1
- package/dist/assets/{index-CbX-xoud.js → index-Ct_pxETK.js} +1 -1
- package/dist/assets/index-DCyWLHnx.js +428 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.html +2 -2
- package/dist/index.js +5383 -3804
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -4
- package/src/components/StudioLeftSidebar.tsx +2 -3
- package/src/components/StudioRightPanel.tsx +10 -4
- package/src/components/editor/AnimationCard.test.tsx +100 -0
- package/src/components/editor/AnimationCard.tsx +5 -2
- package/src/components/editor/EaseCurveSection.test.tsx +348 -0
- package/src/components/editor/EaseCurveSection.tsx +444 -237
- package/src/components/editor/EaseParamFields.test.tsx +188 -0
- package/src/components/editor/EaseParamFields.tsx +246 -0
- package/src/components/editor/PropertyPanel.test.tsx +9 -7
- package/src/components/editor/PropertyPanelFlat.tsx +74 -88
- package/src/components/editor/colorGradingScopePatch.test.ts +3 -3
- package/src/components/editor/easeCurveSvg.tsx +65 -0
- package/src/components/editor/easePresetLibrary.test.ts +114 -0
- package/src/components/editor/easePresetLibrary.ts +49 -0
- package/src/components/editor/gsapAnimationConstants.ts +15 -13
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +73 -40
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +85 -66
- package/src/components/editor/propertyPanelFlatEffectControl.tsx +96 -0
- package/src/components/editor/propertyPanelFlatEffectSpecs.ts +305 -0
- package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +331 -0
- package/src/components/editor/propertyPanelFlatEffectsSection.tsx +503 -0
- package/src/components/editor/propertyPanelFlatOverlaysSection.test.tsx +113 -0
- package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +108 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +5 -5
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -0
- package/src/components/editor/propertyPanelPresetPreview.ts +36 -0
- package/src/components/editor/propertyPanelTypes.ts +13 -0
- package/src/components/editor/useColorGradingController.test.ts +235 -30
- package/src/components/editor/useColorGradingController.ts +46 -88
- package/src/components/editor/useColorGradingPreviews.ts +307 -0
- package/src/components/nle/NLEContext.tsx +1 -1
- package/src/contexts/PanelLayoutContext.tsx +3 -6
- package/src/hooks/useBlockCatalog.ts +31 -13
- package/src/hooks/useBlockHandlers.ts +22 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +15 -0
- package/src/hooks/useGsapScriptCommits.ts +14 -1
- package/src/hooks/usePanelLayout.test.ts +68 -1
- package/src/hooks/usePanelLayout.ts +72 -34
- package/src/player/components/PlayerControls.tsx +68 -229
- package/src/player/components/ShortcutsPanel.tsx +2 -4
- package/src/player/components/SpeedMenu.tsx +1 -2
- package/src/telemetry/events.test.ts +9 -0
- package/src/telemetry/events.ts +5 -0
- package/src/utils/blockInstaller.test.ts +67 -0
- package/src/utils/blockInstaller.ts +162 -109
- package/src/utils/studioUiPreferences.test.ts +5 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/dist/assets/index-CAeU317U.js +0 -428
- package/dist/assets/index-Ceyz8Qt2.css +0 -1
- package/src/player/components/PlayerControls.test.ts +0 -20
- package/src/player/components/useSeekBarDrag.ts +0 -169
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.72",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -46,11 +46,11 @@
|
|
|
46
46
|
"gsap": "^3.13.0",
|
|
47
47
|
"marked": "^14.1.4",
|
|
48
48
|
"mediabunny": "^1.45.3",
|
|
49
|
-
"@hyperframes/core": "0.7.
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/studio-server": "0.7.
|
|
53
|
-
"@hyperframes/parsers": "0.7.
|
|
49
|
+
"@hyperframes/core": "0.7.72",
|
|
50
|
+
"@hyperframes/player": "0.7.72",
|
|
51
|
+
"@hyperframes/sdk": "0.7.72",
|
|
52
|
+
"@hyperframes/studio-server": "0.7.72",
|
|
53
|
+
"@hyperframes/parsers": "0.7.72"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/react": "19",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"vite": "^6.4.2",
|
|
66
66
|
"vitest": "^3.2.4",
|
|
67
67
|
"zustand": "^5.0.0",
|
|
68
|
-
"@hyperframes/producer": "0.7.
|
|
68
|
+
"@hyperframes/producer": "0.7.72"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -12,10 +12,7 @@ import { useFileManager } from "./hooks/useFileManager";
|
|
|
12
12
|
import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
|
|
13
13
|
import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion";
|
|
14
14
|
import { useTimelineEditing } from "./hooks/useTimelineEditing";
|
|
15
|
-
import {
|
|
16
|
-
persistTimelineMoveEditsAtomically,
|
|
17
|
-
type TimelineMoveOperation,
|
|
18
|
-
} from "./hooks/timelineMoveAdapter";
|
|
15
|
+
import { persistTimelineMoveEditsAtomically } from "./hooks/timelineMoveAdapter";
|
|
19
16
|
import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes";
|
|
20
17
|
import type { TimelineStackingReorderIntent } from "./player/components/timelineStacking";
|
|
21
18
|
import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
|
|
@@ -65,6 +62,7 @@ import {
|
|
|
65
62
|
} from "./utils/studioUrlState";
|
|
66
63
|
import { trackStudioSessionStart } from "./telemetry/events";
|
|
67
64
|
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
|
|
65
|
+
type TimelineMoveOperation = Parameters<typeof persistTimelineMoveEditsAtomically>[2];
|
|
68
66
|
// fallow-ignore-next-line complexity
|
|
69
67
|
export function StudioApp() {
|
|
70
68
|
const { projectId, resolving, waitingForServer } = useServerConnection();
|
|
@@ -204,6 +202,7 @@ export function StudioApp() {
|
|
|
204
202
|
setActiveBlockParams,
|
|
205
203
|
handleAddBlock,
|
|
206
204
|
handleTimelineBlockDrop,
|
|
205
|
+
handleAddMediaOverlay,
|
|
207
206
|
handlePreviewBlockDrop,
|
|
208
207
|
} = useBlockHandlers({
|
|
209
208
|
projectId,
|
|
@@ -536,6 +535,7 @@ export function StudioApp() {
|
|
|
536
535
|
domEditSaveTimestampRef={domEditSaveTimestampRef}
|
|
537
536
|
recordEdit={editHistory.recordEdit}
|
|
538
537
|
onToggleElementHidden={timelineEditing.handleToggleElementHidden}
|
|
538
|
+
onAddMediaOverlay={handleAddMediaOverlay}
|
|
539
539
|
/>
|
|
540
540
|
)
|
|
541
541
|
}
|
|
@@ -38,7 +38,7 @@ export function StudioLeftSidebar({
|
|
|
38
38
|
const {
|
|
39
39
|
leftCollapsed,
|
|
40
40
|
leftWidth,
|
|
41
|
-
|
|
41
|
+
adjustPanelWidth,
|
|
42
42
|
toggleLeftSidebar,
|
|
43
43
|
handlePanelResizeStart,
|
|
44
44
|
handlePanelResizeMove,
|
|
@@ -173,8 +173,7 @@ export function StudioLeftSidebar({
|
|
|
173
173
|
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
|
174
174
|
e.preventDefault();
|
|
175
175
|
const delta = e.key === "ArrowLeft" ? -16 : 16;
|
|
176
|
-
|
|
177
|
-
setLeftWidth(Math.max(160, Math.min(maxLeft, leftWidth + delta)));
|
|
176
|
+
adjustPanelWidth("left", delta);
|
|
178
177
|
}}
|
|
179
178
|
>
|
|
180
179
|
{/* Expanded hit zone: 8px wide, centered on the 3px seam */}
|
|
@@ -31,7 +31,10 @@ import {
|
|
|
31
31
|
EMPTY_COLOR_GRADING_SCOPE_RESULT,
|
|
32
32
|
type ColorGradingScope,
|
|
33
33
|
} from "./studioColorGradingScope";
|
|
34
|
-
import type {
|
|
34
|
+
import type {
|
|
35
|
+
AddMediaOverlayHandler,
|
|
36
|
+
BackgroundRemovalProgress,
|
|
37
|
+
} from "./editor/propertyPanelTypes";
|
|
35
38
|
import { timelineKeysForSelections, type ToggleHiddenHandler } from "../utils/studioHelpers";
|
|
36
39
|
import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize";
|
|
37
40
|
|
|
@@ -71,6 +74,7 @@ export interface StudioRightPanelProps extends StudioEditPersistenceProps {
|
|
|
71
74
|
files: Record<string, { before: string; after: string }>;
|
|
72
75
|
}) => Promise<void>;
|
|
73
76
|
onToggleElementHidden?: ToggleHiddenHandler;
|
|
77
|
+
onAddMediaOverlay?: AddMediaOverlayHandler;
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
// fallow-ignore-next-line complexity
|
|
@@ -88,10 +92,11 @@ export function StudioRightPanel({
|
|
|
88
92
|
domEditSaveTimestampRef,
|
|
89
93
|
recordEdit,
|
|
90
94
|
onToggleElementHidden,
|
|
95
|
+
onAddMediaOverlay,
|
|
91
96
|
}: StudioRightPanelProps) {
|
|
92
97
|
const {
|
|
93
98
|
rightWidth,
|
|
94
|
-
|
|
99
|
+
adjustPanelWidth,
|
|
95
100
|
rightPanelTab,
|
|
96
101
|
setRightPanelTab,
|
|
97
102
|
rightInspectorPanes,
|
|
@@ -372,6 +377,7 @@ export function StudioRightPanel({
|
|
|
372
377
|
onRemoveTextField={handleDomRemoveTextField}
|
|
373
378
|
onAskAgent={handleAskAgent}
|
|
374
379
|
onImportAssets={handleImportFiles}
|
|
380
|
+
onAddMediaOverlay={onAddMediaOverlay}
|
|
375
381
|
fontAssets={fontAssets}
|
|
376
382
|
onImportFonts={handleImportFonts}
|
|
377
383
|
previewIframeRef={previewIframeRef}
|
|
@@ -458,7 +464,7 @@ export function StudioRightPanel({
|
|
|
458
464
|
e.preventDefault();
|
|
459
465
|
// Panel is right-anchored: ArrowLeft grows it, ArrowRight shrinks it.
|
|
460
466
|
const delta = e.key === "ArrowLeft" ? 16 : -16;
|
|
461
|
-
|
|
467
|
+
adjustPanelWidth("right", delta);
|
|
462
468
|
}}
|
|
463
469
|
>
|
|
464
470
|
{/* Expanded hit zone: 8px wide, centered on the 3px seam */}
|
|
@@ -467,7 +473,7 @@ export function StudioRightPanel({
|
|
|
467
473
|
<div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
|
468
474
|
</div>
|
|
469
475
|
<div
|
|
470
|
-
className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-800 bg-neutral-
|
|
476
|
+
className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-800 bg-neutral-950"
|
|
471
477
|
style={{ width: rightWidth }}
|
|
472
478
|
>
|
|
473
479
|
{captionEditMode ? (
|
|
@@ -5,11 +5,16 @@ import { createRoot } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { AnimationCard } from "./AnimationCard";
|
|
7
7
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
8
|
+
import { EASE_PRESETS } from "./easePresetLibrary";
|
|
9
|
+
|
|
10
|
+
const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn());
|
|
11
|
+
vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit }));
|
|
8
12
|
|
|
9
13
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
14
|
|
|
11
15
|
afterEach(() => {
|
|
12
16
|
document.body.innerHTML = "";
|
|
17
|
+
trackStudioSegmentEaseEdit.mockClear();
|
|
13
18
|
});
|
|
14
19
|
|
|
15
20
|
function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
|
|
@@ -26,6 +31,101 @@ function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
|
|
|
26
31
|
|
|
27
32
|
const noop = () => {};
|
|
28
33
|
|
|
34
|
+
function selectPreset(host: HTMLElement, presetId: string): string {
|
|
35
|
+
const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId);
|
|
36
|
+
if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`);
|
|
37
|
+
|
|
38
|
+
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
|
|
39
|
+
expect(dropdown).not.toBeNull();
|
|
40
|
+
act(() => dropdown?.click());
|
|
41
|
+
|
|
42
|
+
const preset = host.querySelector<HTMLButtonElement>(`[data-ease-preset-id="${presetId}"]`);
|
|
43
|
+
expect(preset).not.toBeNull();
|
|
44
|
+
act(() => preset?.click());
|
|
45
|
+
return presetConfig.ease;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderExpandedCard({
|
|
49
|
+
animation,
|
|
50
|
+
flat,
|
|
51
|
+
onUpdateMeta = vi.fn(),
|
|
52
|
+
onUpdateKeyframeEase = vi.fn(),
|
|
53
|
+
}: {
|
|
54
|
+
animation: GsapAnimation;
|
|
55
|
+
flat?: boolean;
|
|
56
|
+
onUpdateMeta?: ReturnType<typeof vi.fn>;
|
|
57
|
+
onUpdateKeyframeEase?: ReturnType<typeof vi.fn>;
|
|
58
|
+
}) {
|
|
59
|
+
const host = document.createElement("div");
|
|
60
|
+
document.body.append(host);
|
|
61
|
+
const root = createRoot(host);
|
|
62
|
+
act(() => {
|
|
63
|
+
root.render(
|
|
64
|
+
<AnimationCard
|
|
65
|
+
animation={animation}
|
|
66
|
+
defaultExpanded
|
|
67
|
+
flat={flat}
|
|
68
|
+
onUpdateProperty={noop}
|
|
69
|
+
onUpdateMeta={onUpdateMeta}
|
|
70
|
+
onDeleteAnimation={noop}
|
|
71
|
+
onAddProperty={noop}
|
|
72
|
+
onRemoveProperty={noop}
|
|
73
|
+
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
|
74
|
+
/>,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
return { host, root };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("AnimationCard ease editing", () => {
|
|
81
|
+
it("commits one preset change to the selected keyframe segment", () => {
|
|
82
|
+
const onUpdateKeyframeEase = vi.fn();
|
|
83
|
+
const animation = baseAnimation({
|
|
84
|
+
keyframes: {
|
|
85
|
+
format: "percentage",
|
|
86
|
+
keyframes: [
|
|
87
|
+
{ percentage: 0, properties: { opacity: 0 } },
|
|
88
|
+
{ percentage: 50, properties: { opacity: 0.5 } },
|
|
89
|
+
{ percentage: 100, properties: { opacity: 1 } },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
const view = renderExpandedCard({ animation, onUpdateKeyframeEase });
|
|
94
|
+
|
|
95
|
+
const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
|
|
96
|
+
button.textContent?.includes("0% → 50%"),
|
|
97
|
+
);
|
|
98
|
+
expect(segment).toBeDefined();
|
|
99
|
+
act(() => segment?.click());
|
|
100
|
+
const ease = selectPreset(view.host, "quad-out");
|
|
101
|
+
|
|
102
|
+
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
|
|
103
|
+
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledExactlyOnceWith({
|
|
104
|
+
ease,
|
|
105
|
+
});
|
|
106
|
+
act(() => view.root.unmount());
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("commits one preset change through flat tween metadata", () => {
|
|
110
|
+
const onUpdateMeta = vi.fn();
|
|
111
|
+
const onUpdateKeyframeEase = vi.fn();
|
|
112
|
+
const animation = baseAnimation({ id: "flat-tween" });
|
|
113
|
+
const view = renderExpandedCard({
|
|
114
|
+
animation,
|
|
115
|
+
flat: true,
|
|
116
|
+
onUpdateMeta,
|
|
117
|
+
onUpdateKeyframeEase,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const ease = selectPreset(view.host, "quad-out");
|
|
121
|
+
|
|
122
|
+
expect(onUpdateMeta).toHaveBeenCalledExactlyOnceWith(animation.id, { ease });
|
|
123
|
+
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
|
|
124
|
+
expect(trackStudioSegmentEaseEdit).not.toHaveBeenCalled();
|
|
125
|
+
act(() => view.root.unmount());
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
29
129
|
describe("AnimationCard flat branch", () => {
|
|
30
130
|
it("renders a mint border-left and panel-token colors when flat", () => {
|
|
31
131
|
const host = document.createElement("div");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo, useCallback, useMemo, useState } from "react";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
3
|
import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants";
|
|
4
|
+
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
|
|
4
5
|
import { RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
|
5
6
|
import { MetricField, SelectField } from "./propertyPanelPrimitives";
|
|
6
7
|
import { controlPointsForGsapEase } from "./studioMotion";
|
|
@@ -264,7 +265,10 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
264
265
|
globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"}
|
|
265
266
|
expandedPct={expandedKfPct}
|
|
266
267
|
onToggle={setExpandedKfPct}
|
|
267
|
-
onEaseCommit={(pct, ease) =>
|
|
268
|
+
onEaseCommit={(pct, ease) => {
|
|
269
|
+
onUpdateKeyframeEase(animation.id, pct, ease);
|
|
270
|
+
trackStudioSegmentEaseEdit({ ease });
|
|
271
|
+
}}
|
|
268
272
|
onApplyAll={
|
|
269
273
|
onSetAllKeyframeEases
|
|
270
274
|
? (ease) => onSetAllKeyframeEases(animation.id, ease)
|
|
@@ -292,7 +296,6 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
292
296
|
/>
|
|
293
297
|
<EaseCurveSection
|
|
294
298
|
ease={easeName}
|
|
295
|
-
duration={animation.duration}
|
|
296
299
|
onCustomEaseCommit={(customEase) => {
|
|
297
300
|
const easeKey = animation.keyframes ? "easeEach" : "ease";
|
|
298
301
|
onUpdateMeta(animation.id, { [easeKey]: customEase });
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act, useState } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { EaseCurveSection, MiniCurveSvg } from "./EaseCurveSection";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
document.body.innerHTML = "";
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function renderSection(ease = "none", onCustomEaseCommit = vi.fn()) {
|
|
15
|
+
const host = document.createElement("div");
|
|
16
|
+
document.body.append(host);
|
|
17
|
+
const root = createRoot(host);
|
|
18
|
+
act(() => {
|
|
19
|
+
root.render(<EaseCurveSection ease={ease} onCustomEaseCommit={onCustomEaseCommit} />);
|
|
20
|
+
});
|
|
21
|
+
return { host, root, onCustomEaseCommit };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The preset grid now lives behind the Figma-style ease-type dropdown; open it
|
|
25
|
+
// before querying preset tiles.
|
|
26
|
+
function openPresetGrid(host: HTMLElement): void {
|
|
27
|
+
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
|
|
28
|
+
act(() => dropdown!.click());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function presetPath(host: HTMLElement, id: string): string | null | undefined {
|
|
32
|
+
return host
|
|
33
|
+
.querySelector<HTMLButtonElement>(`[data-ease-preset-id="${id}"]`)
|
|
34
|
+
?.querySelector("path")
|
|
35
|
+
?.getAttribute("d");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function countPathExtrema(path: string): number {
|
|
39
|
+
const values = Array.from(path.matchAll(/[ML][^,]+,([^ ]+)/g), (match) => Number(match[1]));
|
|
40
|
+
const directions = values
|
|
41
|
+
.slice(1)
|
|
42
|
+
.map((value, index) => Math.sign(value - values[index]!))
|
|
43
|
+
.filter((direction) => direction !== 0);
|
|
44
|
+
return directions.filter((direction, index) => index > 0 && direction !== directions[index - 1])
|
|
45
|
+
.length;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderStatefulSection(initialEase = "none", onCustomEaseCommit = vi.fn()) {
|
|
49
|
+
const host = document.createElement("div");
|
|
50
|
+
document.body.append(host);
|
|
51
|
+
const root = createRoot(host);
|
|
52
|
+
const Harness = () => {
|
|
53
|
+
const [ease, setEase] = useState(initialEase);
|
|
54
|
+
return (
|
|
55
|
+
<EaseCurveSection
|
|
56
|
+
ease={ease}
|
|
57
|
+
onCustomEaseCommit={(nextEase) => {
|
|
58
|
+
onCustomEaseCommit(nextEase);
|
|
59
|
+
setEase(nextEase);
|
|
60
|
+
}}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
act(() => root.render(<Harness />));
|
|
65
|
+
return { host, root, onCustomEaseCommit };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clickMode(host: HTMLElement, mode: "curve" | "spring" | "wiggle"): void {
|
|
69
|
+
const toggle = host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`);
|
|
70
|
+
expect(toggle).not.toBeNull();
|
|
71
|
+
act(() => toggle!.click());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function presetIds(host: HTMLElement): string[] {
|
|
75
|
+
return Array.from(host.querySelectorAll<HTMLElement>("[data-ease-preset-id]"), (preset) =>
|
|
76
|
+
preset.getAttribute("data-ease-preset-id"),
|
|
77
|
+
).filter((id): id is string => id !== null);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function editorLabel(host: HTMLElement): string | null {
|
|
81
|
+
return host.querySelector("[data-ease-type-dropdown] span")?.textContent ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("EaseCurveSection preset grid", () => {
|
|
85
|
+
it.each([
|
|
86
|
+
["curve", "none", "linear", ["flow-7", "spring-bouncy"]],
|
|
87
|
+
["spring", "spring(0.42)", "spring-bouncy", ["linear", "flow-7"]],
|
|
88
|
+
["wiggle", "wiggle(3,easeInOut,0.12)", "flow-7", ["linear", "spring-bouncy"]],
|
|
89
|
+
] as const)("shows only %s presets", (_mode, ease, includedPreset, excludedPresets) => {
|
|
90
|
+
const { host, root } = renderSection(ease);
|
|
91
|
+
openPresetGrid(host);
|
|
92
|
+
const ids = presetIds(host);
|
|
93
|
+
|
|
94
|
+
expect(ids).toContain(includedPreset);
|
|
95
|
+
for (const id of excludedPresets) expect(ids).not.toContain(id);
|
|
96
|
+
|
|
97
|
+
act(() => root.unmount());
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("renders a wiggle graph and fields without curve handles or fallback copy", () => {
|
|
101
|
+
const { host, root } = renderSection("wiggle(3,easeInOut,0.12)");
|
|
102
|
+
const graph = host.querySelector<SVGElement>('svg[viewBox="0 0 216 288"]');
|
|
103
|
+
|
|
104
|
+
expect(graph?.querySelector("path")).not.toBeNull();
|
|
105
|
+
expect(graph?.querySelectorAll(".cursor-grab")).toHaveLength(0);
|
|
106
|
+
expect(host.querySelector('[aria-label="Wiggle count"]')).not.toBeNull();
|
|
107
|
+
expect(host.querySelector('[aria-label="Wiggle type"]')).not.toBeNull();
|
|
108
|
+
expect(host.querySelector('[aria-label="Wiggle amplitude"]')).not.toBeNull();
|
|
109
|
+
expect(host.textContent).not.toContain("switch to");
|
|
110
|
+
|
|
111
|
+
act(() => root.unmount());
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it.each([
|
|
115
|
+
["spring(0.6)", "Bouncy"],
|
|
116
|
+
["spring(0.37)", "Custom spring"],
|
|
117
|
+
["wiggle(2,uniform,0.3)", "Custom wiggle"],
|
|
118
|
+
["custom(M0,0 C0.1,0.2 0.8,0.9 1,1)", "Custom bezier"],
|
|
119
|
+
])("labels %s as %s", (ease, expectedLabel) => {
|
|
120
|
+
const { host, root } = renderSection(ease);
|
|
121
|
+
|
|
122
|
+
expect(editorLabel(host)).toBe(expectedLabel);
|
|
123
|
+
|
|
124
|
+
act(() => root.unmount());
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("commits the selected preset through the existing custom-ease callback", () => {
|
|
128
|
+
const { host, root, onCustomEaseCommit } = renderSection("wiggle(3,easeInOut,0.12)");
|
|
129
|
+
openPresetGrid(host);
|
|
130
|
+
const tile = host.querySelector<HTMLButtonElement>('[data-ease-preset-id="flow-7"]');
|
|
131
|
+
expect(tile).not.toBeNull();
|
|
132
|
+
|
|
133
|
+
act(() => tile!.click());
|
|
134
|
+
|
|
135
|
+
expect(onCustomEaseCommit).toHaveBeenCalledTimes(1);
|
|
136
|
+
expect(onCustomEaseCommit).toHaveBeenCalledWith("wiggle(7,easeInOut,0.06)");
|
|
137
|
+
act(() => root.unmount());
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("commits Hold as a segment ease", () => {
|
|
141
|
+
const { host, root, onCustomEaseCommit } = renderSection();
|
|
142
|
+
openPresetGrid(host);
|
|
143
|
+
const tile = host.querySelector<HTMLButtonElement>('[data-ease-preset-id="hold"]');
|
|
144
|
+
expect(tile).not.toBeNull();
|
|
145
|
+
|
|
146
|
+
act(() => tile!.click());
|
|
147
|
+
|
|
148
|
+
expect(onCustomEaseCommit).toHaveBeenCalledWith("hold");
|
|
149
|
+
act(() => root.unmount());
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("draws Hold as a flat step with an end jump", () => {
|
|
153
|
+
const { host, root } = renderSection();
|
|
154
|
+
openPresetGrid(host);
|
|
155
|
+
const path = presetPath(host, "hold");
|
|
156
|
+
|
|
157
|
+
expect(path).toBe("M3,21 L21,21 L21,3");
|
|
158
|
+
|
|
159
|
+
act(() => root.unmount());
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("draws Hold as the same flat step in the big editor graph", () => {
|
|
163
|
+
const host = document.createElement("div");
|
|
164
|
+
document.body.append(host);
|
|
165
|
+
const root = createRoot(host);
|
|
166
|
+
act(() => {
|
|
167
|
+
root.render(<EaseCurveSection ease="hold" onCustomEaseCommit={vi.fn()} />);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const path = host
|
|
171
|
+
.querySelector<SVGElement>('svg[viewBox="0 0 216 288"]')
|
|
172
|
+
?.querySelector("path")
|
|
173
|
+
?.getAttribute("d");
|
|
174
|
+
expect(path).toBe("M16,236 L200,236 L200,52");
|
|
175
|
+
expect(host.querySelectorAll('[role="slider"]')).toHaveLength(0);
|
|
176
|
+
|
|
177
|
+
act(() => root.unmount());
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("preserves negative custom-ease control points in both curve graphs", () => {
|
|
181
|
+
const ease = "custom(M0,0 C0.3,-0.5 0.7,1.5 1,1)";
|
|
182
|
+
const host = document.createElement("div");
|
|
183
|
+
document.body.append(host);
|
|
184
|
+
const root = createRoot(host);
|
|
185
|
+
act(() => {
|
|
186
|
+
root.render(
|
|
187
|
+
<>
|
|
188
|
+
<MiniCurveSvg ease={ease} active={false} />
|
|
189
|
+
<EaseCurveSection ease={ease} onCustomEaseCommit={vi.fn()} />
|
|
190
|
+
</>,
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const miniPath = host
|
|
195
|
+
.querySelector<SVGElement>('svg[viewBox="0 0 24 24"]')
|
|
196
|
+
?.querySelector("path")
|
|
197
|
+
?.getAttribute("d");
|
|
198
|
+
const editorPath = host
|
|
199
|
+
.querySelector<SVGElement>('svg[viewBox="0 0 216 288"]')
|
|
200
|
+
?.querySelector("path")
|
|
201
|
+
?.getAttribute("d");
|
|
202
|
+
expect(miniPath).toBe("M3,21 C8.399999999999999,30 15.6,-6 21,3");
|
|
203
|
+
expect(editorPath).toBe("M16,236 C71.19999999999999,328 144.79999999999998,-40 200,52");
|
|
204
|
+
|
|
205
|
+
act(() => root.unmount());
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("draws wiggle presets as sampled oscillating glyphs", () => {
|
|
209
|
+
const { host, root } = renderSection("wiggle(3,easeInOut,0.12)");
|
|
210
|
+
openPresetGrid(host);
|
|
211
|
+
const flow = presetPath(host, "flow-7");
|
|
212
|
+
const bounce = presetPath(host, "bounce-3");
|
|
213
|
+
|
|
214
|
+
expect(flow).toMatch(/^M.* L/);
|
|
215
|
+
expect(bounce).toMatch(/^M.* L/);
|
|
216
|
+
expect(flow).not.toBe(bounce);
|
|
217
|
+
expect(countPathExtrema(flow!)).toBeGreaterThan(8);
|
|
218
|
+
expect(countPathExtrema(bounce!)).toBeGreaterThan(8);
|
|
219
|
+
|
|
220
|
+
act(() => root.unmount());
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("draws Flow with increasing-frequency sampled glyphs", () => {
|
|
224
|
+
const { host, root } = renderSection("wiggle(3,easeInOut,0.12)");
|
|
225
|
+
openPresetGrid(host);
|
|
226
|
+
const flow1 = presetPath(host, "flow-1");
|
|
227
|
+
const flow7 = presetPath(host, "flow-7");
|
|
228
|
+
|
|
229
|
+
expect(flow1).toMatch(/^M.* L/);
|
|
230
|
+
expect(flow7).toMatch(/^M.* L/);
|
|
231
|
+
expect(flow1).not.toBe(flow7);
|
|
232
|
+
|
|
233
|
+
act(() => root.unmount());
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("draws explicit wiggle amplitudes in sampled glyphs", () => {
|
|
237
|
+
const host = document.createElement("div");
|
|
238
|
+
document.body.append(host);
|
|
239
|
+
const root = createRoot(host);
|
|
240
|
+
act(() =>
|
|
241
|
+
root.render(
|
|
242
|
+
<>
|
|
243
|
+
<MiniCurveSvg ease="wiggle(3,easeInOut,0.1)" active={false} />
|
|
244
|
+
<MiniCurveSvg ease="wiggle(3,easeInOut,0.2)" active={false} />
|
|
245
|
+
</>,
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
const paths = Array.from(host.querySelectorAll("path"), (path) => path.getAttribute("d"));
|
|
249
|
+
|
|
250
|
+
expect(paths[0]).not.toBe(paths[1]);
|
|
251
|
+
|
|
252
|
+
act(() => root.unmount());
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("commits each mode default when switching modes", () => {
|
|
256
|
+
const { host, root, onCustomEaseCommit } = renderStatefulSection();
|
|
257
|
+
|
|
258
|
+
clickMode(host, "spring");
|
|
259
|
+
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.42)");
|
|
260
|
+
|
|
261
|
+
clickMode(host, "curve");
|
|
262
|
+
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.16,1 0.3,1 1,1)");
|
|
263
|
+
|
|
264
|
+
clickMode(host, "wiggle");
|
|
265
|
+
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("wiggle(3,easeInOut,0.12)");
|
|
266
|
+
|
|
267
|
+
act(() => root.unmount());
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("keeps spring bounce editing wired to the custom-ease callback", () => {
|
|
271
|
+
const { host, root, onCustomEaseCommit } = renderStatefulSection("spring(0.37)");
|
|
272
|
+
const bounceInput = host.querySelector<HTMLInputElement>('[aria-label="Spring bounce"]');
|
|
273
|
+
expect(bounceInput).not.toBeNull();
|
|
274
|
+
|
|
275
|
+
act(() => {
|
|
276
|
+
bounceInput!.focus();
|
|
277
|
+
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
278
|
+
valueSetter?.call(bounceInput, "0.7");
|
|
279
|
+
bounceInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
280
|
+
});
|
|
281
|
+
expect(onCustomEaseCommit).not.toHaveBeenCalled();
|
|
282
|
+
act(() => bounceInput!.blur());
|
|
283
|
+
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.7)");
|
|
284
|
+
|
|
285
|
+
act(() => root.unmount());
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("keeps curve handles draggable and commits the edited custom curve", () => {
|
|
289
|
+
const { host, root, onCustomEaseCommit } = renderSection("power2.out");
|
|
290
|
+
const graph = host.querySelector<SVGSVGElement>('svg[viewBox="0 0 216 288"]');
|
|
291
|
+
const handle = graph?.querySelector<SVGCircleElement>(".cursor-grab");
|
|
292
|
+
expect(graph).not.toBeNull();
|
|
293
|
+
expect(handle).not.toBeNull();
|
|
294
|
+
vi.spyOn(graph!, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 216, 288));
|
|
295
|
+
handle!.setPointerCapture = vi.fn();
|
|
296
|
+
|
|
297
|
+
act(() => {
|
|
298
|
+
handle!.dispatchEvent(
|
|
299
|
+
new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 46, clientY: 52 }),
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
act(() => {
|
|
303
|
+
graph!.dispatchEvent(
|
|
304
|
+
new PointerEvent("pointermove", {
|
|
305
|
+
bubbles: true,
|
|
306
|
+
pointerId: 1,
|
|
307
|
+
clientX: 108,
|
|
308
|
+
clientY: 144,
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
act(() => {
|
|
313
|
+
graph!.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }));
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.5,0.5 0.3,1 1,1)");
|
|
317
|
+
|
|
318
|
+
act(() => root.unmount());
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("nudges curve handles with the keyboard", () => {
|
|
322
|
+
const { host, root, onCustomEaseCommit } = renderSection("power2.out");
|
|
323
|
+
const firstHandle = host.querySelector<SVGCircleElement>('[role="slider"]');
|
|
324
|
+
expect(firstHandle).not.toBeNull();
|
|
325
|
+
|
|
326
|
+
act(() => {
|
|
327
|
+
firstHandle!.dispatchEvent(
|
|
328
|
+
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }),
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.17,1 0.3,1 1,1)");
|
|
333
|
+
act(() => root.unmount());
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("closes the preset menu with Escape and returns focus to its trigger", () => {
|
|
337
|
+
const { host, root } = renderSection();
|
|
338
|
+
const trigger = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]")!;
|
|
339
|
+
openPresetGrid(host);
|
|
340
|
+
|
|
341
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
342
|
+
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
|
|
343
|
+
|
|
344
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
345
|
+
expect(document.activeElement).toBe(trigger);
|
|
346
|
+
act(() => root.unmount());
|
|
347
|
+
});
|
|
348
|
+
});
|