@hyperframes/studio 0.6.95 → 0.6.96
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/index-B0twsRu0.css +1 -0
- package/dist/assets/index-BA979yF1.js +251 -0
- package/dist/assets/{index-CAANLw9Q.js → index-BWFaypdT.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +10 -5
- package/src/components/SaveQueuePausedBanner.tsx +23 -0
- package/src/components/StudioPreviewArea.tsx +7 -0
- package/src/components/StudioRightPanel.tsx +1 -38
- package/src/components/editor/DomEditOverlay.test.ts +169 -29
- package/src/components/editor/DomEditOverlay.tsx +13 -23
- package/src/components/editor/GestureRecordControl.tsx +98 -0
- package/src/components/editor/PropertyPanel.tsx +22 -38
- package/src/components/editor/domEditing.test.ts +84 -0
- package/src/components/editor/domEditingLayers.ts +19 -0
- package/src/components/editor/domEditingRootLayer.ts +64 -0
- package/src/components/editor/manualEditingAvailability.test.ts +1 -2
- package/src/components/editor/manualEditingAvailability.ts +0 -7
- package/src/contexts/DomEditContext.tsx +1 -6
- package/src/hooks/gsapScriptCommitHelpers.ts +128 -0
- package/src/hooks/useDomEditCommits.ts +97 -123
- package/src/hooks/useDomEditPositionPatchCommit.ts +53 -0
- package/src/hooks/useDomEditSession.ts +59 -65
- package/src/hooks/useFileManager.ts +19 -5
- package/src/hooks/useGsapAnimationFetchFallback.ts +19 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +25 -0
- package/src/hooks/useGsapScriptCommits.ts +152 -140
- package/src/hooks/useGsapSelectionHandlers.ts +38 -8
- package/src/hooks/usePreviewPersistence.ts +90 -51
- package/src/hooks/useSafeGsapCommitMutation.ts +66 -0
- package/src/hooks/useStudioContextValue.ts +3 -19
- package/src/player/hooks/useTimelinePlayer.ts +25 -28
- package/src/player/lib/playbackAdapter.test.ts +86 -1
- package/src/player/lib/playbackAdapter.ts +62 -0
- package/src/utils/domEditSaveQueue.test.ts +117 -0
- package/src/utils/domEditSaveQueue.ts +87 -0
- package/src/utils/studioHelpers.ts +1 -1
- package/src/utils/studioSaveDiagnostics.test.ts +127 -0
- package/src/utils/studioSaveDiagnostics.ts +200 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/studioUrlState.ts +2 -8
- package/dist/assets/index-DujOjou6.js +0 -251
- package/dist/assets/index-rm9tn9nH.css +0 -1
- package/src/components/editor/EaseCurveEditor.tsx +0 -221
- package/src/components/editor/MotionPanel.tsx +0 -277
- package/src/components/editor/MotionPanelFields.tsx +0 -185
- package/src/components/editor/MotionPathOverlay.tsx +0 -146
- package/src/components/editor/SpringEaseEditor.tsx +0 -256
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
import { useState, useRef, useEffect, type ReactNode } from "react";
|
|
2
|
-
import { Zap } from "../../icons/SystemIcons";
|
|
3
|
-
|
|
4
|
-
const FIELD =
|
|
5
|
-
"min-w-0 rounded-xl border border-neutral-800 bg-neutral-900/95 px-3 py-2 text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] transition-colors focus-within:border-neutral-600";
|
|
6
|
-
export const LABEL = "text-[11px] font-medium uppercase tracking-[0.18em] text-neutral-500";
|
|
7
|
-
export const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
|
|
8
|
-
|
|
9
|
-
export function formatNumericValue(value: number): string {
|
|
10
|
-
const rounded = Math.round(value * 100) / 100;
|
|
11
|
-
return Number.isInteger(rounded)
|
|
12
|
-
? `${rounded}`
|
|
13
|
-
: rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function clampMotionNumber(
|
|
17
|
-
value: number | null,
|
|
18
|
-
min: number,
|
|
19
|
-
max: number,
|
|
20
|
-
fallback: number,
|
|
21
|
-
): number {
|
|
22
|
-
if (value == null || !Number.isFinite(value)) return fallback;
|
|
23
|
-
return Math.min(max, Math.max(min, value));
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function parsePlainNumber(value: string): number | null {
|
|
27
|
-
const parsed = Number.parseFloat(value.trim());
|
|
28
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// ── CommitField ──
|
|
32
|
-
|
|
33
|
-
function CommitField({
|
|
34
|
-
value,
|
|
35
|
-
disabled,
|
|
36
|
-
onCommit,
|
|
37
|
-
}: {
|
|
38
|
-
value: string;
|
|
39
|
-
disabled?: boolean;
|
|
40
|
-
onCommit: (nextValue: string) => void;
|
|
41
|
-
}) {
|
|
42
|
-
const [draft, setDraft] = useState(value);
|
|
43
|
-
const focusedRef = useRef(false);
|
|
44
|
-
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
if (!focusedRef.current) setDraft(value);
|
|
47
|
-
}, [value]);
|
|
48
|
-
|
|
49
|
-
const commitDraft = () => {
|
|
50
|
-
focusedRef.current = false;
|
|
51
|
-
const next = draft.trim();
|
|
52
|
-
if (next !== value) onCommit(next);
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
return (
|
|
56
|
-
<input
|
|
57
|
-
type="text"
|
|
58
|
-
value={draft}
|
|
59
|
-
disabled={disabled}
|
|
60
|
-
onFocus={() => {
|
|
61
|
-
focusedRef.current = true;
|
|
62
|
-
}}
|
|
63
|
-
onChange={(event) => setDraft(event.target.value)}
|
|
64
|
-
onBlur={commitDraft}
|
|
65
|
-
onKeyDown={(event) => {
|
|
66
|
-
if (event.key === "Enter") (event.target as HTMLInputElement).blur();
|
|
67
|
-
}}
|
|
68
|
-
className="w-full min-w-0 bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
69
|
-
/>
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// ── DetailField ──
|
|
74
|
-
|
|
75
|
-
export function DetailField({
|
|
76
|
-
label,
|
|
77
|
-
value,
|
|
78
|
-
disabled,
|
|
79
|
-
onCommit,
|
|
80
|
-
}: {
|
|
81
|
-
label: string;
|
|
82
|
-
value: string;
|
|
83
|
-
disabled?: boolean;
|
|
84
|
-
onCommit: (nextValue: string) => void;
|
|
85
|
-
}) {
|
|
86
|
-
return (
|
|
87
|
-
<label className="grid min-w-0 gap-1.5">
|
|
88
|
-
<span className={LABEL}>{label}</span>
|
|
89
|
-
<div className={FIELD}>
|
|
90
|
-
<CommitField value={value} disabled={disabled} onCommit={onCommit} />
|
|
91
|
-
</div>
|
|
92
|
-
</label>
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ── SegmentedControl ──
|
|
97
|
-
|
|
98
|
-
export function SegmentedControl({
|
|
99
|
-
value,
|
|
100
|
-
options,
|
|
101
|
-
onChange,
|
|
102
|
-
}: {
|
|
103
|
-
value: string;
|
|
104
|
-
options: Array<{ label: string; value: string }>;
|
|
105
|
-
onChange: (value: string) => void;
|
|
106
|
-
}) {
|
|
107
|
-
return (
|
|
108
|
-
<div className="grid grid-cols-3 gap-1 rounded-2xl border border-neutral-800 bg-neutral-950 p-1">
|
|
109
|
-
{options.map((option) => (
|
|
110
|
-
<button
|
|
111
|
-
key={option.value}
|
|
112
|
-
type="button"
|
|
113
|
-
onClick={() => onChange(option.value)}
|
|
114
|
-
className={`h-9 rounded-xl text-[11px] font-semibold transition-colors ${
|
|
115
|
-
option.value === value
|
|
116
|
-
? "bg-neutral-800 text-white shadow-sm"
|
|
117
|
-
: "text-neutral-500 hover:bg-neutral-900 hover:text-neutral-200"
|
|
118
|
-
}`}
|
|
119
|
-
>
|
|
120
|
-
{option.label}
|
|
121
|
-
</button>
|
|
122
|
-
))}
|
|
123
|
-
</div>
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// ── SelectField ──
|
|
128
|
-
|
|
129
|
-
export function SelectField({
|
|
130
|
-
label,
|
|
131
|
-
value,
|
|
132
|
-
options,
|
|
133
|
-
onChange,
|
|
134
|
-
}: {
|
|
135
|
-
label: string;
|
|
136
|
-
value: string;
|
|
137
|
-
options: readonly string[];
|
|
138
|
-
onChange: (value: string) => void;
|
|
139
|
-
}) {
|
|
140
|
-
return (
|
|
141
|
-
<label className="grid min-w-0 gap-1.5">
|
|
142
|
-
<span className={LABEL}>{label}</span>
|
|
143
|
-
<div className={FIELD}>
|
|
144
|
-
<select
|
|
145
|
-
value={value}
|
|
146
|
-
onChange={(event) => onChange(event.target.value)}
|
|
147
|
-
className="w-full min-w-0 appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none"
|
|
148
|
-
>
|
|
149
|
-
{options.map((option) => (
|
|
150
|
-
<option key={option} value={option}>
|
|
151
|
-
{option}
|
|
152
|
-
</option>
|
|
153
|
-
))}
|
|
154
|
-
</select>
|
|
155
|
-
</div>
|
|
156
|
-
</label>
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// ── MotionSection ──
|
|
161
|
-
|
|
162
|
-
export function MotionSection({
|
|
163
|
-
title,
|
|
164
|
-
children,
|
|
165
|
-
accessory,
|
|
166
|
-
}: {
|
|
167
|
-
title: string;
|
|
168
|
-
children: ReactNode;
|
|
169
|
-
accessory?: ReactNode;
|
|
170
|
-
}) {
|
|
171
|
-
return (
|
|
172
|
-
<section className="border-b border-neutral-800 px-4 py-5">
|
|
173
|
-
<div className="mb-4 flex items-center justify-between gap-3">
|
|
174
|
-
<div className="flex items-center gap-3">
|
|
175
|
-
<Zap size={15} className="text-neutral-500" />
|
|
176
|
-
<h3 className="text-[11px] font-semibold uppercase tracking-[0.22em] text-neutral-300">
|
|
177
|
-
{title}
|
|
178
|
-
</h3>
|
|
179
|
-
</div>
|
|
180
|
-
{accessory}
|
|
181
|
-
</div>
|
|
182
|
-
{children}
|
|
183
|
-
</section>
|
|
184
|
-
);
|
|
185
|
-
}
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import { memo, useMemo, type RefObject } from "react";
|
|
2
|
-
import type { ArcPathConfig } from "@hyperframes/core/gsap-parser";
|
|
3
|
-
|
|
4
|
-
interface MotionPathOverlayProps {
|
|
5
|
-
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
6
|
-
arcPath: ArcPathConfig | null;
|
|
7
|
-
waypoints: Array<{ x: number; y: number }> | null;
|
|
8
|
-
elementBaseRect: { left: number; top: number; scaleX: number; scaleY: number } | null;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function buildSvgPath(
|
|
12
|
-
waypoints: Array<{ x: number; y: number }>,
|
|
13
|
-
segments: ArcPathConfig["segments"],
|
|
14
|
-
base: { left: number; top: number; scaleX: number; scaleY: number },
|
|
15
|
-
): string {
|
|
16
|
-
if (waypoints.length < 2) return "";
|
|
17
|
-
|
|
18
|
-
const toPixel = (wp: { x: number; y: number }) => ({
|
|
19
|
-
x: base.left + wp.x * base.scaleX,
|
|
20
|
-
y: base.top + wp.y * base.scaleY,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
const first = toPixel(waypoints[0]!);
|
|
24
|
-
const parts = [`M ${first.x} ${first.y}`];
|
|
25
|
-
|
|
26
|
-
for (let i = 0; i < segments.length && i < waypoints.length - 1; i++) {
|
|
27
|
-
const seg = segments[i]!;
|
|
28
|
-
const end = toPixel(waypoints[i + 1]!);
|
|
29
|
-
|
|
30
|
-
if (seg.cp1 && seg.cp2) {
|
|
31
|
-
const c1 = toPixel(seg.cp1);
|
|
32
|
-
const c2 = toPixel(seg.cp2);
|
|
33
|
-
parts.push(`C ${c1.x} ${c1.y} ${c2.x} ${c2.y} ${end.x} ${end.y}`);
|
|
34
|
-
} else {
|
|
35
|
-
const start = toPixel(waypoints[i]!);
|
|
36
|
-
const dx = end.x - start.x;
|
|
37
|
-
const dy = end.y - start.y;
|
|
38
|
-
const c = seg.curviness ?? 1;
|
|
39
|
-
const offset = c * Math.abs(dx) * 0.25;
|
|
40
|
-
const c1x = start.x + dx * 0.33;
|
|
41
|
-
const c1y = start.y + dy * 0.33 - offset;
|
|
42
|
-
const c2x = start.x + dx * 0.66;
|
|
43
|
-
const c2y = start.y + dy * 0.66 - offset;
|
|
44
|
-
parts.push(`C ${c1x} ${c1y} ${c2x} ${c2y} ${end.x} ${end.y}`);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return parts.join(" ");
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
52
|
-
arcPath,
|
|
53
|
-
waypoints,
|
|
54
|
-
elementBaseRect,
|
|
55
|
-
}: MotionPathOverlayProps) {
|
|
56
|
-
const pathD = useMemo(() => {
|
|
57
|
-
if (!arcPath?.enabled || !waypoints || waypoints.length < 2 || !elementBaseRect) return "";
|
|
58
|
-
return buildSvgPath(waypoints, arcPath.segments, elementBaseRect);
|
|
59
|
-
}, [arcPath, waypoints, elementBaseRect]);
|
|
60
|
-
|
|
61
|
-
const anchorPoints = useMemo(() => {
|
|
62
|
-
if (!waypoints || !elementBaseRect) return [];
|
|
63
|
-
return waypoints.map((wp) => ({
|
|
64
|
-
x: elementBaseRect.left + wp.x * elementBaseRect.scaleX,
|
|
65
|
-
y: elementBaseRect.top + wp.y * elementBaseRect.scaleY,
|
|
66
|
-
}));
|
|
67
|
-
}, [waypoints, elementBaseRect]);
|
|
68
|
-
|
|
69
|
-
const controlPoints = useMemo(() => {
|
|
70
|
-
if (!arcPath?.enabled || !elementBaseRect) return [];
|
|
71
|
-
const points: Array<{
|
|
72
|
-
segIndex: number;
|
|
73
|
-
type: "cp1" | "cp2";
|
|
74
|
-
x: number;
|
|
75
|
-
y: number;
|
|
76
|
-
anchorX: number;
|
|
77
|
-
anchorY: number;
|
|
78
|
-
}> = [];
|
|
79
|
-
for (let i = 0; i < arcPath.segments.length; i++) {
|
|
80
|
-
const seg = arcPath.segments[i]!;
|
|
81
|
-
if (seg.cp1 && seg.cp2 && waypoints) {
|
|
82
|
-
const anchor1 = waypoints[i]!;
|
|
83
|
-
const anchor2 = waypoints[i + 1]!;
|
|
84
|
-
points.push({
|
|
85
|
-
segIndex: i,
|
|
86
|
-
type: "cp1",
|
|
87
|
-
x: elementBaseRect.left + seg.cp1.x * elementBaseRect.scaleX,
|
|
88
|
-
y: elementBaseRect.top + seg.cp1.y * elementBaseRect.scaleY,
|
|
89
|
-
anchorX: elementBaseRect.left + anchor1.x * elementBaseRect.scaleX,
|
|
90
|
-
anchorY: elementBaseRect.top + anchor1.y * elementBaseRect.scaleY,
|
|
91
|
-
});
|
|
92
|
-
points.push({
|
|
93
|
-
segIndex: i,
|
|
94
|
-
type: "cp2",
|
|
95
|
-
x: elementBaseRect.left + seg.cp2.x * elementBaseRect.scaleX,
|
|
96
|
-
y: elementBaseRect.top + seg.cp2.y * elementBaseRect.scaleY,
|
|
97
|
-
anchorX: elementBaseRect.left + anchor2.x * elementBaseRect.scaleX,
|
|
98
|
-
anchorY: elementBaseRect.top + anchor2.y * elementBaseRect.scaleY,
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return points;
|
|
103
|
-
}, [arcPath, waypoints, elementBaseRect]);
|
|
104
|
-
|
|
105
|
-
if (!pathD) return null;
|
|
106
|
-
|
|
107
|
-
return (
|
|
108
|
-
<svg className="absolute inset-0 pointer-events-none z-20 overflow-visible">
|
|
109
|
-
<path d={pathD} fill="none" stroke="rgba(45, 212, 191, 0.4)" strokeWidth={2} />
|
|
110
|
-
|
|
111
|
-
{controlPoints.map((cp) => (
|
|
112
|
-
<g key={`${cp.segIndex}-${cp.type}`}>
|
|
113
|
-
<line
|
|
114
|
-
x1={cp.anchorX}
|
|
115
|
-
y1={cp.anchorY}
|
|
116
|
-
x2={cp.x}
|
|
117
|
-
y2={cp.y}
|
|
118
|
-
stroke="rgba(167, 139, 250, 0.3)"
|
|
119
|
-
strokeWidth={1}
|
|
120
|
-
strokeDasharray="3 2"
|
|
121
|
-
/>
|
|
122
|
-
<circle
|
|
123
|
-
cx={cp.x}
|
|
124
|
-
cy={cp.y}
|
|
125
|
-
r={4}
|
|
126
|
-
fill="#a78bfa"
|
|
127
|
-
className="pointer-events-auto cursor-grab"
|
|
128
|
-
/>
|
|
129
|
-
</g>
|
|
130
|
-
))}
|
|
131
|
-
|
|
132
|
-
{anchorPoints.map((pt, i) => (
|
|
133
|
-
<circle
|
|
134
|
-
key={i}
|
|
135
|
-
cx={pt.x}
|
|
136
|
-
cy={pt.y}
|
|
137
|
-
r={5}
|
|
138
|
-
fill="#3CE6AC"
|
|
139
|
-
stroke="rgba(255,255,255,0.5)"
|
|
140
|
-
strokeWidth={1}
|
|
141
|
-
className="pointer-events-auto cursor-pointer"
|
|
142
|
-
/>
|
|
143
|
-
))}
|
|
144
|
-
</svg>
|
|
145
|
-
);
|
|
146
|
-
});
|
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
import { useState, useRef, useEffect, useCallback } from "react";
|
|
2
|
-
import { generateSpringEaseData, SPRING_PRESETS } from "@hyperframes/core/spring-ease";
|
|
3
|
-
import { LABEL } from "./MotionPanelFields";
|
|
4
|
-
import { RotateCcw } from "../../icons/SystemIcons";
|
|
5
|
-
|
|
6
|
-
interface SpringParams {
|
|
7
|
-
mass: number;
|
|
8
|
-
stiffness: number;
|
|
9
|
-
damping: number;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const DEFAULT_SPRING: SpringParams = { mass: 1, stiffness: 180, damping: 12 };
|
|
13
|
-
|
|
14
|
-
const SLIDERS: {
|
|
15
|
-
key: keyof SpringParams;
|
|
16
|
-
label: string;
|
|
17
|
-
min: number;
|
|
18
|
-
max: number;
|
|
19
|
-
step: number;
|
|
20
|
-
}[] = [
|
|
21
|
-
{ key: "mass", label: "Mass", min: 0.1, max: 5, step: 0.1 },
|
|
22
|
-
{ key: "stiffness", label: "Stiffness", min: 10, max: 500, step: 10 },
|
|
23
|
-
{ key: "damping", label: "Damping", min: 1, max: 50, step: 1 },
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
function springValue(mass: number, stiffness: number, damping: number, t: number): number {
|
|
27
|
-
const w0 = Math.sqrt(stiffness / mass);
|
|
28
|
-
const zeta = damping / (2 * Math.sqrt(stiffness * mass));
|
|
29
|
-
if (zeta < 1) {
|
|
30
|
-
const wd = w0 * Math.sqrt(1 - zeta * zeta);
|
|
31
|
-
return (
|
|
32
|
-
1 - Math.exp(-zeta * w0 * t) * (Math.cos(wd * t) + ((zeta * w0) / wd) * Math.sin(wd * t))
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
if (zeta === 1) {
|
|
36
|
-
return 1 - (1 + w0 * t) * Math.exp(-w0 * t);
|
|
37
|
-
}
|
|
38
|
-
const s1 = -w0 * (zeta - Math.sqrt(zeta * zeta - 1));
|
|
39
|
-
const s2 = -w0 * (zeta + Math.sqrt(zeta * zeta - 1));
|
|
40
|
-
return 1 + (s1 * Math.exp(s2 * t) - s2 * Math.exp(s1 * t)) / (s2 - s1);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function springSimDuration(mass: number, stiffness: number, damping: number): number {
|
|
44
|
-
const w0 = Math.sqrt(stiffness / mass);
|
|
45
|
-
const zeta = damping / (2 * Math.sqrt(stiffness * mass));
|
|
46
|
-
if (zeta < 1) return Math.min(5 / (zeta * w0), 10);
|
|
47
|
-
const decayRate = zeta * w0 - w0 * Math.sqrt(zeta * zeta - 1);
|
|
48
|
-
return Math.min(4 / Math.max(decayRate, 0.01), 10);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function buildSpringPath(
|
|
52
|
-
params: SpringParams,
|
|
53
|
-
mapFn: (point: { x: number; y: number }) => { x: number; y: number },
|
|
54
|
-
): string {
|
|
55
|
-
const steps = 64;
|
|
56
|
-
const simDur = springSimDuration(params.mass, params.stiffness, params.damping);
|
|
57
|
-
const commands: string[] = [];
|
|
58
|
-
for (let i = 0; i <= steps; i++) {
|
|
59
|
-
const t = i / steps;
|
|
60
|
-
const simT = t * simDur;
|
|
61
|
-
const y = springValue(params.mass, params.stiffness, params.damping, simT);
|
|
62
|
-
const mapped = mapFn({ x: t, y });
|
|
63
|
-
commands.push(`${i === 0 ? "M" : "L"}${mapped.x.toFixed(2)},${mapped.y.toFixed(2)}`);
|
|
64
|
-
}
|
|
65
|
-
return commands.join(" ");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function SpringEaseEditor({
|
|
69
|
-
onCommit,
|
|
70
|
-
}: {
|
|
71
|
-
onCommit: (easeId: string, easeData: string) => void;
|
|
72
|
-
}) {
|
|
73
|
-
const [params, setParams] = useState<SpringParams>(DEFAULT_SPRING);
|
|
74
|
-
const commitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
75
|
-
|
|
76
|
-
const scheduleCommit = useCallback(
|
|
77
|
-
(next: SpringParams) => {
|
|
78
|
-
if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current);
|
|
79
|
-
commitTimeoutRef.current = setTimeout(() => {
|
|
80
|
-
const data = generateSpringEaseData(next.mass, next.stiffness, next.damping);
|
|
81
|
-
const id = `spring-m${next.mass}-k${next.stiffness}-d${next.damping}`;
|
|
82
|
-
onCommit(id, data);
|
|
83
|
-
}, 120);
|
|
84
|
-
},
|
|
85
|
-
[onCommit],
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
useEffect(() => {
|
|
89
|
-
return () => {
|
|
90
|
-
if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current);
|
|
91
|
-
};
|
|
92
|
-
}, []);
|
|
93
|
-
|
|
94
|
-
const updateParam = (key: keyof SpringParams, value: number) => {
|
|
95
|
-
const next = { ...params, [key]: value };
|
|
96
|
-
setParams(next);
|
|
97
|
-
scheduleCommit(next);
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const applyPreset = (preset: (typeof SPRING_PRESETS)[number]) => {
|
|
101
|
-
const next: SpringParams = {
|
|
102
|
-
mass: preset.mass,
|
|
103
|
-
stiffness: preset.stiffness,
|
|
104
|
-
damping: preset.damping,
|
|
105
|
-
};
|
|
106
|
-
setParams(next);
|
|
107
|
-
const data = generateSpringEaseData(next.mass, next.stiffness, next.damping);
|
|
108
|
-
onCommit(preset.name, data);
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
const reset = () => {
|
|
112
|
-
setParams(DEFAULT_SPRING);
|
|
113
|
-
const data = generateSpringEaseData(
|
|
114
|
-
DEFAULT_SPRING.mass,
|
|
115
|
-
DEFAULT_SPRING.stiffness,
|
|
116
|
-
DEFAULT_SPRING.damping,
|
|
117
|
-
);
|
|
118
|
-
onCommit("spring-bouncy", data);
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
// SVG layout matching EaseCurveEditor proportions
|
|
122
|
-
const width = 324;
|
|
123
|
-
const height = 214;
|
|
124
|
-
const plot = { left: 46, top: 24, width: 242, height: 146 };
|
|
125
|
-
const yMin = -0.2;
|
|
126
|
-
const yMax = 1.3;
|
|
127
|
-
|
|
128
|
-
const mapPoint = (point: { x: number; y: number }) => ({
|
|
129
|
-
x: plot.left + point.x * plot.width,
|
|
130
|
-
y: plot.top + ((yMax - point.y) / (yMax - yMin)) * plot.height,
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
const curvePath = buildSpringPath(params, mapPoint);
|
|
134
|
-
const start = mapPoint({ x: 0, y: 0 });
|
|
135
|
-
const end = mapPoint({ x: 1, y: 1 });
|
|
136
|
-
|
|
137
|
-
const activePreset = SPRING_PRESETS.find(
|
|
138
|
-
(p) =>
|
|
139
|
-
p.mass === params.mass && p.stiffness === params.stiffness && p.damping === params.damping,
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
return (
|
|
143
|
-
<div className="overflow-hidden rounded-2xl border border-neutral-800 bg-black/40">
|
|
144
|
-
<div className="flex items-center justify-between gap-3 border-b border-neutral-800 px-3 py-2">
|
|
145
|
-
<div>
|
|
146
|
-
<div className={LABEL}>Spring Ease</div>
|
|
147
|
-
<div className="mt-1 font-mono text-[10px] text-neutral-500">
|
|
148
|
-
{activePreset?.label ?? `m${params.mass} k${params.stiffness} d${params.damping}`}
|
|
149
|
-
</div>
|
|
150
|
-
</div>
|
|
151
|
-
<button
|
|
152
|
-
type="button"
|
|
153
|
-
onClick={reset}
|
|
154
|
-
className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-800 bg-neutral-950 px-3 text-[10px] font-semibold uppercase tracking-[0.14em] text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-100"
|
|
155
|
-
>
|
|
156
|
-
<RotateCcw size={13} />
|
|
157
|
-
Reset
|
|
158
|
-
</button>
|
|
159
|
-
</div>
|
|
160
|
-
|
|
161
|
-
{/* Curve preview */}
|
|
162
|
-
<svg viewBox={`0 0 ${width} ${height}`} className="block w-full select-none touch-none">
|
|
163
|
-
<rect x="0" y="0" width={width} height={height} fill="transparent" />
|
|
164
|
-
{[0, 0.5, 1].map((value) => {
|
|
165
|
-
const mapped = mapPoint({ x: 0, y: value });
|
|
166
|
-
return (
|
|
167
|
-
<g key={value}>
|
|
168
|
-
<line
|
|
169
|
-
x1={plot.left}
|
|
170
|
-
x2={plot.left + plot.width}
|
|
171
|
-
y1={mapped.y}
|
|
172
|
-
y2={mapped.y}
|
|
173
|
-
stroke="rgba(255,255,255,0.12)"
|
|
174
|
-
strokeDasharray="5 8"
|
|
175
|
-
/>
|
|
176
|
-
<text
|
|
177
|
-
x={plot.left - 12}
|
|
178
|
-
y={mapped.y + 4}
|
|
179
|
-
textAnchor="end"
|
|
180
|
-
className="fill-neutral-500 text-[10px] font-semibold"
|
|
181
|
-
>
|
|
182
|
-
{value}
|
|
183
|
-
</text>
|
|
184
|
-
</g>
|
|
185
|
-
);
|
|
186
|
-
})}
|
|
187
|
-
<line
|
|
188
|
-
x1={plot.left}
|
|
189
|
-
x2={plot.left + plot.width}
|
|
190
|
-
y1={plot.top + plot.height}
|
|
191
|
-
y2={plot.top + plot.height}
|
|
192
|
-
stroke="rgba(255,255,255,0.18)"
|
|
193
|
-
/>
|
|
194
|
-
<line
|
|
195
|
-
x1={plot.left}
|
|
196
|
-
x2={plot.left}
|
|
197
|
-
y1={plot.top}
|
|
198
|
-
y2={plot.top + plot.height}
|
|
199
|
-
stroke="rgba(255,255,255,0.18)"
|
|
200
|
-
/>
|
|
201
|
-
<path d={curvePath} fill="none" stroke="#ffdd57" strokeWidth="4" strokeLinecap="round" />
|
|
202
|
-
<circle cx={start.x} cy={start.y} r="5" fill="#ffdd57" />
|
|
203
|
-
<circle cx={end.x} cy={end.y} r="5" fill="#ffdd57" />
|
|
204
|
-
</svg>
|
|
205
|
-
|
|
206
|
-
{/* Presets */}
|
|
207
|
-
<div className="flex gap-1.5 border-t border-neutral-800 px-3 py-2">
|
|
208
|
-
{SPRING_PRESETS.map((preset) => {
|
|
209
|
-
const isActive =
|
|
210
|
-
preset.mass === params.mass &&
|
|
211
|
-
preset.stiffness === params.stiffness &&
|
|
212
|
-
preset.damping === params.damping;
|
|
213
|
-
return (
|
|
214
|
-
<button
|
|
215
|
-
key={preset.name}
|
|
216
|
-
type="button"
|
|
217
|
-
onClick={() => applyPreset(preset)}
|
|
218
|
-
className={`flex-1 rounded-lg px-1.5 py-1.5 text-[10px] font-semibold transition-colors ${
|
|
219
|
-
isActive
|
|
220
|
-
? "border border-yellow-400/40 bg-yellow-400/10 text-yellow-300"
|
|
221
|
-
: "border border-neutral-800 bg-neutral-950 text-neutral-500 hover:border-neutral-700 hover:text-neutral-300"
|
|
222
|
-
}`}
|
|
223
|
-
>
|
|
224
|
-
{preset.label}
|
|
225
|
-
</button>
|
|
226
|
-
);
|
|
227
|
-
})}
|
|
228
|
-
</div>
|
|
229
|
-
|
|
230
|
-
{/* Sliders */}
|
|
231
|
-
<div className="space-y-3 border-t border-neutral-800 px-3 py-3">
|
|
232
|
-
{SLIDERS.map((slider) => (
|
|
233
|
-
<div key={slider.key}>
|
|
234
|
-
<div className="mb-1 flex items-center justify-between">
|
|
235
|
-
<span className="text-[10px] font-medium uppercase tracking-[0.14em] text-neutral-500">
|
|
236
|
-
{slider.label}
|
|
237
|
-
</span>
|
|
238
|
-
<span className="min-w-[36px] text-right font-mono text-[10px] text-neutral-400">
|
|
239
|
-
{params[slider.key]}
|
|
240
|
-
</span>
|
|
241
|
-
</div>
|
|
242
|
-
<input
|
|
243
|
-
type="range"
|
|
244
|
-
min={slider.min}
|
|
245
|
-
max={slider.max}
|
|
246
|
-
step={slider.step}
|
|
247
|
-
value={params[slider.key]}
|
|
248
|
-
onChange={(e) => updateParam(slider.key, Number(e.target.value))}
|
|
249
|
-
className="h-1 w-full cursor-pointer appearance-none rounded-full bg-neutral-800 accent-yellow-400 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-yellow-400"
|
|
250
|
-
/>
|
|
251
|
-
</div>
|
|
252
|
-
))}
|
|
253
|
-
</div>
|
|
254
|
-
</div>
|
|
255
|
-
);
|
|
256
|
-
}
|