@hyperframes/studio 0.7.71 → 0.7.73
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-C1X90psg.js → hyperframes-player-DGlzhf_s.js} +1 -1
- package/dist/assets/index-B37rWXo4.css +1 -0
- package/dist/assets/{index-DzxDHPR1.js → index-CbVTOJ-E.js} +1 -1
- package/dist/assets/{index-nY4lLBqM.js → index-CcA5WVJv.js} +1 -1
- package/dist/assets/index-DXcLI2wu.js +428 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.html +2 -2
- package/dist/index.js +7762 -4261
- 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 +75 -88
- package/src/components/editor/colorGradingFrameAnalysis.test.ts +101 -0
- package/src/components/editor/colorGradingFrameAnalysis.ts +203 -0
- 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/propertyPanelColorCurveGraph.tsx +549 -0
- package/src/components/editor/propertyPanelColorCurves.test.tsx +158 -0
- package/src/components/editor/propertyPanelColorCurves.tsx +185 -0
- package/src/components/editor/propertyPanelColorGradingControls.tsx +115 -77
- package/src/components/editor/propertyPanelColorScopes.tsx +258 -0
- package/src/components/editor/propertyPanelColorSecondary.test.tsx +181 -0
- package/src/components/editor/propertyPanelColorSecondary.tsx +448 -0
- package/src/components/editor/propertyPanelColorWheels.test.tsx +119 -0
- package/src/components/editor/propertyPanelColorWheels.tsx +334 -0
- package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +109 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +239 -41
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +260 -300
- 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.tsx +2 -0
- package/src/components/editor/propertyPanelGradingNumberField.test.tsx +79 -0
- package/src/components/editor/propertyPanelGradingNumberField.tsx +116 -0
- package/src/components/editor/propertyPanelPresetPreview.ts +36 -0
- package/src/components/editor/propertyPanelTypes.ts +13 -0
- package/src/components/editor/useColorGradingController.test.ts +331 -10
- package/src/components/editor/useColorGradingController.ts +57 -90
- package/src/components/editor/useColorGradingPreviews.ts +350 -0
- package/src/components/editor/useColorGradingScopes.test.tsx +143 -0
- package/src/components/editor/useColorGradingScopes.ts +62 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +30 -1
- 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/icons/SystemIcons.tsx +4 -0
- 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-CSCh2Vrp.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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { parseWiggleEase } from "@hyperframes/core/wiggle-ease";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { EaseBezierField, SpringBounceField, WiggleField } from "./EaseParamFields";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
const roots: Root[] = [];
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
act(() => roots.splice(0).forEach((root) => root.unmount()));
|
|
15
|
+
document.body.innerHTML = "";
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function renderField(field: React.ReactNode): HTMLElement {
|
|
19
|
+
const host = document.createElement("div");
|
|
20
|
+
document.body.append(host);
|
|
21
|
+
const root = createRoot(host);
|
|
22
|
+
roots.push(root);
|
|
23
|
+
act(() => root.render(field));
|
|
24
|
+
return host;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parsedWiggle(ease: string) {
|
|
28
|
+
const config = parseWiggleEase(ease);
|
|
29
|
+
expect(config).not.toBeNull();
|
|
30
|
+
if (!config) throw new Error(`Expected a valid wiggle ease: ${ease}`);
|
|
31
|
+
return config;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function expectWiggleCommit(onCommit: ReturnType<typeof vi.fn>, ease: string): void {
|
|
35
|
+
expect(onCommit).toHaveBeenLastCalledWith(ease);
|
|
36
|
+
const emitted = onCommit.mock.lastCall?.[0];
|
|
37
|
+
expect(typeof emitted === "string" ? parseWiggleEase(emitted) : null).not.toBeNull();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function inputValue(input: HTMLInputElement, value: string): void {
|
|
41
|
+
act(() => {
|
|
42
|
+
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
43
|
+
valueSetter?.call(input, value);
|
|
44
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function commitInputValue(input: HTMLInputElement, value: string): void {
|
|
49
|
+
act(() => input.focus());
|
|
50
|
+
inputValue(input, value);
|
|
51
|
+
act(() => input.blur());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Render a default wiggle field, type `value` into the input labelled `label`,
|
|
55
|
+
// and return the commit spy — the shared body of the input-edit cases.
|
|
56
|
+
function editWiggle(label: string, value: string): ReturnType<typeof vi.fn> {
|
|
57
|
+
const onCommit = vi.fn();
|
|
58
|
+
const host = renderField(
|
|
59
|
+
<WiggleField config={parsedWiggle("wiggle(6,easeOut,0.26)")} onCommit={onCommit} />,
|
|
60
|
+
);
|
|
61
|
+
const input = host.querySelector<HTMLInputElement>(`[aria-label="${label}"]`);
|
|
62
|
+
expect(input).not.toBeNull();
|
|
63
|
+
if (input) commitInputValue(input, value);
|
|
64
|
+
return onCommit;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("WiggleField", () => {
|
|
68
|
+
it("reflects a parsed wiggle config", () => {
|
|
69
|
+
const host = renderField(
|
|
70
|
+
<WiggleField config={parsedWiggle("wiggle(6,easeOut,0.26)")} onCommit={vi.fn()} />,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(host.querySelector<HTMLInputElement>('[aria-label="Wiggle count"]')?.value).toBe("6");
|
|
74
|
+
expect(host.querySelector<HTMLSelectElement>('[aria-label="Wiggle type"]')?.value).toBe(
|
|
75
|
+
"easeOut",
|
|
76
|
+
);
|
|
77
|
+
expect(host.querySelector<HTMLInputElement>('[aria-label="Wiggle amplitude"]')?.value).toBe(
|
|
78
|
+
"0.26",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("commits an edited count", () => {
|
|
83
|
+
expectWiggleCommit(editWiggle("Wiggle count", "4"), "wiggle(4,easeOut,0.26)");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("commits an edited type", () => {
|
|
87
|
+
const onCommit = vi.fn();
|
|
88
|
+
const host = renderField(
|
|
89
|
+
<WiggleField config={parsedWiggle("wiggle(6,easeOut,0.26)")} onCommit={onCommit} />,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const select = host.querySelector<HTMLSelectElement>('[aria-label="Wiggle type"]');
|
|
93
|
+
expect(select).not.toBeNull();
|
|
94
|
+
act(() => {
|
|
95
|
+
if (!select) return;
|
|
96
|
+
select.value = "anticipate";
|
|
97
|
+
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expectWiggleCommit(onCommit, "wiggle(6,anticipate,0.26)");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("commits an edited amplitude", () => {
|
|
104
|
+
expectWiggleCommit(editWiggle("Wiggle amplitude", "0.1"), "wiggle(6,easeOut,0.1)");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("seeds and explicitly commits the per-type default amplitude", () => {
|
|
108
|
+
const onCommit = vi.fn();
|
|
109
|
+
const host = renderField(
|
|
110
|
+
<WiggleField config={parsedWiggle("wiggle(3,easeInOut)")} onCommit={onCommit} />,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(host.querySelector<HTMLInputElement>('[aria-label="Wiggle amplitude"]')?.value).toBe(
|
|
114
|
+
"0.08",
|
|
115
|
+
);
|
|
116
|
+
const count = host.querySelector<HTMLInputElement>('[aria-label="Wiggle count"]');
|
|
117
|
+
expect(count).not.toBeNull();
|
|
118
|
+
if (count) commitInputValue(count, "4");
|
|
119
|
+
|
|
120
|
+
expectWiggleCommit(onCommit, "wiggle(4,easeInOut,0.08)");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("ignores an empty amplitude", () => {
|
|
124
|
+
expect(editWiggle("Wiggle amplitude", "")).not.toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("rejects a count below one", () => {
|
|
128
|
+
expect(editWiggle("Wiggle count", "0")).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("moved ease parameter fields", () => {
|
|
133
|
+
it("keeps the spring bounce commit behavior", () => {
|
|
134
|
+
const onCommit = vi.fn();
|
|
135
|
+
const host = renderField(<SpringBounceField springBounce={0.5} onCommit={onCommit} />);
|
|
136
|
+
const input = host.querySelector<HTMLInputElement>('[aria-label="Spring bounce"]');
|
|
137
|
+
expect(input).not.toBeNull();
|
|
138
|
+
if (input) commitInputValue(input, "0.333");
|
|
139
|
+
|
|
140
|
+
expect(onCommit).toHaveBeenLastCalledWith("spring(0.33)");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("commits a numeric draft exactly once when Enter blurs the field", () => {
|
|
144
|
+
const onCommit = vi.fn();
|
|
145
|
+
const host = renderField(<SpringBounceField springBounce={0.5} onCommit={onCommit} />);
|
|
146
|
+
const input = host.querySelector<HTMLInputElement>('[aria-label="Spring bounce"]')!;
|
|
147
|
+
|
|
148
|
+
act(() => input.focus());
|
|
149
|
+
inputValue(input, "0.7");
|
|
150
|
+
act(() => input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })));
|
|
151
|
+
|
|
152
|
+
expect(onCommit).toHaveBeenCalledExactlyOnceWith("spring(0.7)");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("keeps the cubic-bezier tuple commit behavior", () => {
|
|
156
|
+
const onCommit = vi.fn();
|
|
157
|
+
const host = renderField(<EaseBezierField tuple={[0.33, 0, 0.67, 1]} onCommit={onCommit} />);
|
|
158
|
+
const input = host.querySelector<HTMLInputElement>(
|
|
159
|
+
'[aria-label="Cubic bezier control points"]',
|
|
160
|
+
);
|
|
161
|
+
expect(input).not.toBeNull();
|
|
162
|
+
act(() => {
|
|
163
|
+
if (!input) return;
|
|
164
|
+
input.focus();
|
|
165
|
+
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
166
|
+
valueSetter?.call(input, "0.111, 0.222, 0.777, 0.888");
|
|
167
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
168
|
+
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(onCommit).toHaveBeenCalledExactlyOnceWith("custom(M0,0 C0.11,0.22 0.78,0.89 1,1)");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("keeps the bezier input mounted and reports invalid values", () => {
|
|
175
|
+
const onCommit = vi.fn();
|
|
176
|
+
const host = renderField(<EaseBezierField tuple={[0.33, 0, 0.67, 1]} onCommit={onCommit} />);
|
|
177
|
+
const input = host.querySelector<HTMLInputElement>(
|
|
178
|
+
'[aria-label="Cubic bezier control points"]',
|
|
179
|
+
)!;
|
|
180
|
+
|
|
181
|
+
commitInputValue(input, "0.5, 1e9, 0.5, -1e9");
|
|
182
|
+
|
|
183
|
+
expect(input.isConnected).toBe(true);
|
|
184
|
+
expect(input.getAttribute("aria-invalid")).toBe("true");
|
|
185
|
+
expect(host.textContent).toContain("Y values must be between -1 and 2");
|
|
186
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import { parseSpringBounce } from "@hyperframes/core/spring-ease";
|
|
3
|
+
import {
|
|
4
|
+
parseWiggleEase,
|
|
5
|
+
type WiggleEaseConfig,
|
|
6
|
+
type WiggleType,
|
|
7
|
+
} from "@hyperframes/core/wiggle-ease";
|
|
8
|
+
import { roundToCenti } from "../../utils/rounding";
|
|
9
|
+
import { MiniCurveSvg } from "./easeCurveSvg";
|
|
10
|
+
|
|
11
|
+
type Pts = [number, number, number, number];
|
|
12
|
+
|
|
13
|
+
const round2 = roundToCenti;
|
|
14
|
+
const BEZIER_Y_MIN = -1;
|
|
15
|
+
const BEZIER_Y_MAX = 2;
|
|
16
|
+
const WIGGLE_TYPES = ["easeOut", "easeInOut", "anticipate", "uniform"] as const;
|
|
17
|
+
const WIGGLE_DEFAULT_AMPLITUDE = {
|
|
18
|
+
easeOut: 0.16,
|
|
19
|
+
easeInOut: 0.08,
|
|
20
|
+
anticipate: 0.12,
|
|
21
|
+
uniform: 0.14,
|
|
22
|
+
} satisfies Record<WiggleType, number>;
|
|
23
|
+
|
|
24
|
+
function isWiggleType(value: string): value is WiggleType {
|
|
25
|
+
return WIGGLE_TYPES.some((type) => type === value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function commitWiggle(
|
|
29
|
+
onCommit: (ease: string) => void,
|
|
30
|
+
count: number,
|
|
31
|
+
type: WiggleType,
|
|
32
|
+
amplitude: number,
|
|
33
|
+
): void {
|
|
34
|
+
const ease = `wiggle(${count},${type},${roundToCenti(amplitude)})`;
|
|
35
|
+
if (parseWiggleEase(ease)) onCommit(ease);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Editable cubic-bezier control points, Figma-style ("0.33, 0, 0, 1").
|
|
39
|
+
export function EaseBezierField({
|
|
40
|
+
tuple,
|
|
41
|
+
onCommit,
|
|
42
|
+
}: {
|
|
43
|
+
tuple: Pts;
|
|
44
|
+
onCommit: (ease: string) => void;
|
|
45
|
+
}) {
|
|
46
|
+
const text = tuple.join(", ");
|
|
47
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
48
|
+
const errorId = useId();
|
|
49
|
+
const [error, setError] = useState<string | null>(null);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (inputRef.current) inputRef.current.value = text;
|
|
53
|
+
setError(null);
|
|
54
|
+
}, [text]);
|
|
55
|
+
|
|
56
|
+
const commit = (raw: string) => {
|
|
57
|
+
const tokens = raw.trim().split(/[\s,]+/);
|
|
58
|
+
const nums = tokens.map(Number);
|
|
59
|
+
if (tokens.length !== 4 || nums.some((value) => !Number.isFinite(value))) {
|
|
60
|
+
setError("Enter four finite numbers");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const [x1, y1, x2, y2] = nums as [number, number, number, number];
|
|
64
|
+
if (y1 < BEZIER_Y_MIN || y1 > BEZIER_Y_MAX || y2 < BEZIER_Y_MIN || y2 > BEZIER_Y_MAX) {
|
|
65
|
+
setError(`Y values must be between ${BEZIER_Y_MIN} and ${BEZIER_Y_MAX}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const cx = (v: number) => Math.max(0, Math.min(1, v));
|
|
69
|
+
setError(null);
|
|
70
|
+
onCommit(`custom(M0,0 C${round2(cx(x1))},${round2(y1)} ${round2(cx(x2))},${round2(y2)} 1,1)`);
|
|
71
|
+
};
|
|
72
|
+
return (
|
|
73
|
+
<div className="mt-1.5 px-0.5">
|
|
74
|
+
<div className="flex items-center gap-1.5">
|
|
75
|
+
<MiniCurveSvg
|
|
76
|
+
ease={`custom(M0,0 C${tuple[0]},${tuple[1]} ${tuple[2]},${tuple[3]} 1,1)`}
|
|
77
|
+
active
|
|
78
|
+
size={14}
|
|
79
|
+
/>
|
|
80
|
+
<input
|
|
81
|
+
ref={inputRef}
|
|
82
|
+
type="text"
|
|
83
|
+
defaultValue={text}
|
|
84
|
+
aria-label="Cubic bezier control points"
|
|
85
|
+
aria-invalid={error !== null}
|
|
86
|
+
aria-describedby={error ? errorId : undefined}
|
|
87
|
+
onInput={() => setError(null)}
|
|
88
|
+
onKeyDown={(event) => {
|
|
89
|
+
if (event.key === "Enter") event.currentTarget.blur();
|
|
90
|
+
if (event.key === "Escape") {
|
|
91
|
+
event.currentTarget.value = text;
|
|
92
|
+
setError(null);
|
|
93
|
+
}
|
|
94
|
+
}}
|
|
95
|
+
onBlur={(event) => commit(event.currentTarget.value)}
|
|
96
|
+
className={`w-full rounded border bg-black/20 px-1.5 py-1 font-mono text-[10px] text-neutral-300 outline-none ${
|
|
97
|
+
error
|
|
98
|
+
? "border-red-500/70 focus:border-red-400"
|
|
99
|
+
: "border-white/10 focus:border-panel-accent/50"
|
|
100
|
+
}`}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
<p
|
|
104
|
+
id={errorId}
|
|
105
|
+
aria-live="polite"
|
|
106
|
+
className={`mt-1 text-[9px] text-red-400 ${error ? "" : "sr-only"}`}
|
|
107
|
+
>
|
|
108
|
+
{error ?? "Valid bezier values"}
|
|
109
|
+
</p>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function NumericCommitInput({
|
|
115
|
+
label,
|
|
116
|
+
value,
|
|
117
|
+
min,
|
|
118
|
+
max,
|
|
119
|
+
step,
|
|
120
|
+
className,
|
|
121
|
+
onCommit,
|
|
122
|
+
}: {
|
|
123
|
+
label: string;
|
|
124
|
+
value: number;
|
|
125
|
+
min: number;
|
|
126
|
+
max?: number;
|
|
127
|
+
step: number;
|
|
128
|
+
className: string;
|
|
129
|
+
onCommit: (value: number) => void;
|
|
130
|
+
}) {
|
|
131
|
+
const [draft, setDraft] = useState(String(value));
|
|
132
|
+
useEffect(() => setDraft(String(value)), [value]);
|
|
133
|
+
const commit = () => {
|
|
134
|
+
if (draft.trim() === "") {
|
|
135
|
+
setDraft(String(value));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const next = Number(draft);
|
|
139
|
+
if (!Number.isFinite(next) || next < min || (max !== undefined && next > max)) {
|
|
140
|
+
setDraft(String(value));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
onCommit(next);
|
|
144
|
+
};
|
|
145
|
+
return (
|
|
146
|
+
<input
|
|
147
|
+
type="number"
|
|
148
|
+
aria-label={label}
|
|
149
|
+
min={min}
|
|
150
|
+
max={max}
|
|
151
|
+
step={step}
|
|
152
|
+
value={draft}
|
|
153
|
+
onChange={(event) => setDraft(event.currentTarget.value)}
|
|
154
|
+
onBlur={commit}
|
|
155
|
+
onKeyDown={(event) => {
|
|
156
|
+
if (event.key === "Enter") event.currentTarget.blur();
|
|
157
|
+
if (event.key === "Escape") {
|
|
158
|
+
setDraft(String(value));
|
|
159
|
+
}
|
|
160
|
+
}}
|
|
161
|
+
className={className}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function SpringBounceField({
|
|
167
|
+
springBounce,
|
|
168
|
+
onCommit,
|
|
169
|
+
}: {
|
|
170
|
+
springBounce: number;
|
|
171
|
+
onCommit: (ease: string) => void;
|
|
172
|
+
}) {
|
|
173
|
+
return (
|
|
174
|
+
<div className="mt-1.5 flex items-center gap-2 px-0.5 text-[10px] text-neutral-400">
|
|
175
|
+
<span aria-hidden="true">Bounce</span>
|
|
176
|
+
<NumericCommitInput
|
|
177
|
+
label="Spring bounce"
|
|
178
|
+
value={springBounce}
|
|
179
|
+
min={0}
|
|
180
|
+
max={1}
|
|
181
|
+
step={0.01}
|
|
182
|
+
onCommit={(value) => {
|
|
183
|
+
const bounce = parseSpringBounce(`spring(${value})`);
|
|
184
|
+
if (bounce !== null) onCommit(`spring(${round2(bounce)})`);
|
|
185
|
+
}}
|
|
186
|
+
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"
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function WiggleField({
|
|
193
|
+
config,
|
|
194
|
+
onCommit,
|
|
195
|
+
}: {
|
|
196
|
+
config: WiggleEaseConfig;
|
|
197
|
+
onCommit: (ease: string) => void;
|
|
198
|
+
}) {
|
|
199
|
+
const amplitude = config.amplitude ?? WIGGLE_DEFAULT_AMPLITUDE[config.type];
|
|
200
|
+
return (
|
|
201
|
+
<div className="mt-1.5 flex items-center gap-2 px-0.5 text-[10px] text-neutral-400">
|
|
202
|
+
<div className="flex items-center gap-1">
|
|
203
|
+
<span aria-hidden="true">Count</span>
|
|
204
|
+
<NumericCommitInput
|
|
205
|
+
label="Wiggle count"
|
|
206
|
+
value={config.wiggles}
|
|
207
|
+
min={1}
|
|
208
|
+
step={1}
|
|
209
|
+
onCommit={(value) => commitWiggle(onCommit, value, config.type, amplitude)}
|
|
210
|
+
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"
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
<div className="flex items-center gap-1">
|
|
214
|
+
<span aria-hidden="true">Type</span>
|
|
215
|
+
<select
|
|
216
|
+
aria-label="Wiggle type"
|
|
217
|
+
value={config.type}
|
|
218
|
+
onChange={(event) => {
|
|
219
|
+
if (isWiggleType(event.currentTarget.value)) {
|
|
220
|
+
commitWiggle(onCommit, config.wiggles, event.currentTarget.value, amplitude);
|
|
221
|
+
}
|
|
222
|
+
}}
|
|
223
|
+
className="rounded border border-white/10 bg-black/20 px-1.5 py-1 text-[10px] text-neutral-300 outline-none focus:border-panel-accent/50"
|
|
224
|
+
>
|
|
225
|
+
{WIGGLE_TYPES.map((type) => (
|
|
226
|
+
<option key={type} value={type}>
|
|
227
|
+
{type}
|
|
228
|
+
</option>
|
|
229
|
+
))}
|
|
230
|
+
</select>
|
|
231
|
+
</div>
|
|
232
|
+
<div className="flex items-center gap-1">
|
|
233
|
+
<span aria-hidden="true">Amplitude</span>
|
|
234
|
+
<NumericCommitInput
|
|
235
|
+
label="Wiggle amplitude"
|
|
236
|
+
value={amplitude}
|
|
237
|
+
min={0}
|
|
238
|
+
max={1}
|
|
239
|
+
step={0.01}
|
|
240
|
+
onCommit={(value) => commitWiggle(onCommit, config.wiggles, config.type, value)}
|
|
241
|
+
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"
|
|
242
|
+
/>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
@@ -812,10 +812,10 @@ describe("PropertyPanel — Media group (Plan 4)", () => {
|
|
|
812
812
|
// design_handoff scrollable-open-section: collapsed headers before/after the
|
|
813
813
|
// open group render in normal document flow and never move (no sticky, no
|
|
814
814
|
// stacking offsets) — only the open group's own body content scrolls, in a
|
|
815
|
-
// dedicated region between the two fixed header stacks. Worked example:
|
|
816
|
-
// groups [text, style, layout, motion, grade, media], motion open (index 3)
|
|
815
|
+
// dedicated region between the two fixed header stacks. Worked example: 7
|
|
816
|
+
// groups [text, style, layout, motion, grade, effects, media], motion open (index 3)
|
|
817
817
|
// -> text/style/layout render as fixed collapsed headers before it, motion
|
|
818
|
-
// renders as an open header + scrollable body, grade/media render as fixed
|
|
818
|
+
// renders as an open header + scrollable body, grade/effects/media render as fixed
|
|
819
819
|
// collapsed headers after it — in exactly that DOM order, nothing sticky.
|
|
820
820
|
describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)", () => {
|
|
821
821
|
it(
|
|
@@ -839,13 +839,14 @@ describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)",
|
|
|
839
839
|
});
|
|
840
840
|
// Filter to just the group entries (drop any non-group nulls).
|
|
841
841
|
const groupTitles = titles.filter((t): t is string => t !== null);
|
|
842
|
-
expect(groupTitles).toHaveLength(
|
|
842
|
+
expect(groupTitles).toHaveLength(7);
|
|
843
843
|
expect(groupTitles[0]).toContain("Text");
|
|
844
844
|
expect(groupTitles[1]).toContain("Style");
|
|
845
845
|
expect(groupTitles[2]).toContain("Layout");
|
|
846
846
|
expect(groupTitles[3]).toContain("Motion");
|
|
847
847
|
expect(groupTitles[4]).toContain("Grade");
|
|
848
|
-
expect(groupTitles[5]).toContain("
|
|
848
|
+
expect(groupTitles[5]).toContain("Effects");
|
|
849
|
+
expect(groupTitles[6]).toContain("Media");
|
|
849
850
|
|
|
850
851
|
// The open group (Motion, index 3) is the one wrapped in
|
|
851
852
|
// data-flat-group-open, sitting between the before/after collapsed
|
|
@@ -880,14 +881,15 @@ describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)",
|
|
|
880
881
|
const collapsedRows = Array.from(
|
|
881
882
|
host.querySelectorAll<HTMLButtonElement>('[data-flat-group-collapsed="true"]'),
|
|
882
883
|
);
|
|
883
|
-
expect(collapsedRows).toHaveLength(
|
|
884
|
+
expect(collapsedRows).toHaveLength(7);
|
|
884
885
|
const titlesInOrder = collapsedRows.map((el) => el.textContent ?? "");
|
|
885
886
|
expect(titlesInOrder[0]).toContain("Text");
|
|
886
887
|
expect(titlesInOrder[1]).toContain("Style");
|
|
887
888
|
expect(titlesInOrder[2]).toContain("Layout");
|
|
888
889
|
expect(titlesInOrder[3]).toContain("Motion");
|
|
889
890
|
expect(titlesInOrder[4]).toContain("Grade");
|
|
890
|
-
expect(titlesInOrder[5]).toContain("
|
|
891
|
+
expect(titlesInOrder[5]).toContain("Effects");
|
|
892
|
+
expect(titlesInOrder[6]).toContain("Media");
|
|
891
893
|
|
|
892
894
|
const body = host.querySelector('[data-flat-panel-body="true"]');
|
|
893
895
|
expect(body?.querySelector(".overflow-y-auto")).toBeNull();
|