@vsreact/core 0.0.1 → 0.0.3
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/animation.d.ts +38 -0
- package/dist/animation.js +90 -0
- package/dist/bridge.d.ts +7 -0
- package/dist/bridge.js +49 -0
- package/dist/controls.d.ts +126 -0
- package/dist/controls.js +130 -0
- package/dist/cx.d.ts +6 -0
- package/dist/cx.js +26 -0
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.js +15 -0
- package/dist/hostConfig.d.ts +44 -0
- package/dist/hostConfig.js +118 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +13 -0
- package/dist/native.d.ts +6 -0
- package/dist/native.js +16 -0
- package/dist/parameters.d.ts +12 -0
- package/dist/parameters.js +33 -0
- package/dist/primitives.d.ts +70 -0
- package/dist/primitives.js +20 -0
- package/dist/render.d.ts +5 -0
- package/dist/render.js +22 -0
- package/dist/runtime.d.ts +10 -0
- package/dist/runtime.js +68 -0
- package/dist/theme.d.ts +5 -0
- package/dist/theme.js +156 -0
- package/dist/tw.d.ts +13 -0
- package/dist/tw.js +264 -0
- package/package.json +31 -4
- package/src/animation.test.tsx +56 -1
- package/src/animation.ts +71 -0
- package/src/controls.test.tsx +131 -0
- package/src/controls.tsx +352 -19
- package/src/cx.ts +33 -0
- package/src/hooks.ts +18 -0
- package/src/index.ts +50 -24
- package/src/runtime.ts +4 -1
- package/src/theme.ts +76 -1
- package/src/tw.test.ts +26 -0
- package/src/tw.ts +4 -0
package/dist/tw.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { resolveColor, setThemeColors } from "./theme";
|
|
2
|
+
export function configureTheme(theme) {
|
|
3
|
+
if (theme.colors)
|
|
4
|
+
setThemeColors(theme.colors);
|
|
5
|
+
cache.clear();
|
|
6
|
+
}
|
|
7
|
+
const staticClasses = {
|
|
8
|
+
flex: {},
|
|
9
|
+
"flex-row": { flexDirection: "row" },
|
|
10
|
+
"flex-col": { flexDirection: "column" },
|
|
11
|
+
"flex-row-reverse": { flexDirection: "row-reverse" },
|
|
12
|
+
"flex-col-reverse": { flexDirection: "column-reverse" },
|
|
13
|
+
"flex-1": { flex: 1 },
|
|
14
|
+
"flex-auto": { flexGrow: 1, flexShrink: 1 },
|
|
15
|
+
"flex-none": { flexGrow: 0, flexShrink: 0 },
|
|
16
|
+
grow: { flexGrow: 1 },
|
|
17
|
+
"grow-0": { flexGrow: 0 },
|
|
18
|
+
shrink: { flexShrink: 1 },
|
|
19
|
+
"shrink-0": { flexShrink: 0 },
|
|
20
|
+
"flex-wrap": { flexWrap: "wrap" },
|
|
21
|
+
"flex-nowrap": { flexWrap: "nowrap" },
|
|
22
|
+
"items-start": { alignItems: "flex-start" },
|
|
23
|
+
"items-center": { alignItems: "center" },
|
|
24
|
+
"items-end": { alignItems: "flex-end" },
|
|
25
|
+
"items-stretch": { alignItems: "stretch" },
|
|
26
|
+
"items-baseline": { alignItems: "baseline" },
|
|
27
|
+
"justify-start": { justifyContent: "flex-start" },
|
|
28
|
+
"justify-center": { justifyContent: "center" },
|
|
29
|
+
"justify-end": { justifyContent: "flex-end" },
|
|
30
|
+
"justify-between": { justifyContent: "space-between" },
|
|
31
|
+
"justify-around": { justifyContent: "space-around" },
|
|
32
|
+
"justify-evenly": { justifyContent: "space-evenly" },
|
|
33
|
+
"self-start": { alignSelf: "flex-start" },
|
|
34
|
+
"self-center": { alignSelf: "center" },
|
|
35
|
+
"self-end": { alignSelf: "flex-end" },
|
|
36
|
+
"self-stretch": { alignSelf: "stretch" },
|
|
37
|
+
"self-auto": { alignSelf: "auto" },
|
|
38
|
+
absolute: { position: "absolute" },
|
|
39
|
+
relative: { position: "relative" },
|
|
40
|
+
"overflow-hidden": { overflow: "hidden" },
|
|
41
|
+
"overflow-visible": { overflow: "visible" },
|
|
42
|
+
"overflow-y-scroll": { overflow: "scroll" },
|
|
43
|
+
"overflow-scroll": { overflow: "scroll" },
|
|
44
|
+
"w-full": { width: "100%" },
|
|
45
|
+
"h-full": { height: "100%" },
|
|
46
|
+
"text-left": { textAlign: "left" },
|
|
47
|
+
"text-center": { textAlign: "center" },
|
|
48
|
+
"text-right": { textAlign: "right" },
|
|
49
|
+
"font-mono": { fontFamily: "monospace" },
|
|
50
|
+
"font-normal": { fontWeight: 400 },
|
|
51
|
+
"font-medium": { fontWeight: 500 },
|
|
52
|
+
"font-semibold": { fontWeight: 600 },
|
|
53
|
+
"font-bold": { fontWeight: 700 },
|
|
54
|
+
"tracking-tighter": { letterSpacing: -0.8 },
|
|
55
|
+
"tracking-tight": { letterSpacing: -0.4 },
|
|
56
|
+
"tracking-normal": { letterSpacing: 0 },
|
|
57
|
+
"tracking-wide": { letterSpacing: 0.4 },
|
|
58
|
+
"tracking-wider": { letterSpacing: 0.8 },
|
|
59
|
+
"tracking-widest": { letterSpacing: 1.6 },
|
|
60
|
+
border: { borderWidth: 1 },
|
|
61
|
+
"aspect-square": { aspectRatio: 1 },
|
|
62
|
+
"aspect-video": { aspectRatio: 16 / 9 },
|
|
63
|
+
"cursor-pointer": { cursor: "pointer" },
|
|
64
|
+
"cursor-text": { cursor: "text" },
|
|
65
|
+
"cursor-default": { cursor: "default" },
|
|
66
|
+
shadow: { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
|
|
67
|
+
"shadow-sm": { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
|
|
68
|
+
"shadow-md": { shadowColor: "#00000066", shadowRadius: 8, shadowOffsetY: 2 },
|
|
69
|
+
"shadow-lg": { shadowColor: "#00000066", shadowRadius: 12, shadowOffsetY: 4 },
|
|
70
|
+
"shadow-xl": { shadowColor: "#00000066", shadowRadius: 20, shadowOffsetY: 8 },
|
|
71
|
+
};
|
|
72
|
+
const textSizes = {
|
|
73
|
+
xs: 12, sm: 14, base: 16, lg: 18, xl: 20, "2xl": 24, "3xl": 30, "4xl": 36,
|
|
74
|
+
"5xl": 48, "6xl": 60,
|
|
75
|
+
};
|
|
76
|
+
const radiusSizes = {
|
|
77
|
+
"": 4, sm: 2, md: 6, lg: 8, xl: 12, "2xl": 16, "3xl": 24, full: 9999,
|
|
78
|
+
};
|
|
79
|
+
const radiusCorners = {
|
|
80
|
+
"": ["borderTopLeftRadius", "borderTopRightRadius", "borderBottomLeftRadius", "borderBottomRightRadius"],
|
|
81
|
+
t: ["borderTopLeftRadius", "borderTopRightRadius"],
|
|
82
|
+
b: ["borderBottomLeftRadius", "borderBottomRightRadius"],
|
|
83
|
+
l: ["borderTopLeftRadius", "borderBottomLeftRadius"],
|
|
84
|
+
r: ["borderTopRightRadius", "borderBottomRightRadius"],
|
|
85
|
+
tl: ["borderTopLeftRadius"],
|
|
86
|
+
tr: ["borderTopRightRadius"],
|
|
87
|
+
bl: ["borderBottomLeftRadius"],
|
|
88
|
+
br: ["borderBottomRightRadius"],
|
|
89
|
+
};
|
|
90
|
+
// Utilities whose value is a length on the 4px spacing scale.
|
|
91
|
+
const lengthKeys = {
|
|
92
|
+
w: ["width"],
|
|
93
|
+
h: ["height"],
|
|
94
|
+
size: ["width", "height"],
|
|
95
|
+
"min-w": ["minWidth"],
|
|
96
|
+
"min-h": ["minHeight"],
|
|
97
|
+
"max-w": ["maxWidth"],
|
|
98
|
+
"max-h": ["maxHeight"],
|
|
99
|
+
p: ["padding"],
|
|
100
|
+
px: ["paddingLeft", "paddingRight"],
|
|
101
|
+
py: ["paddingTop", "paddingBottom"],
|
|
102
|
+
pt: ["paddingTop"],
|
|
103
|
+
pr: ["paddingRight"],
|
|
104
|
+
pb: ["paddingBottom"],
|
|
105
|
+
pl: ["paddingLeft"],
|
|
106
|
+
m: ["margin"],
|
|
107
|
+
mx: ["marginLeft", "marginRight"],
|
|
108
|
+
my: ["marginTop", "marginBottom"],
|
|
109
|
+
mt: ["marginTop"],
|
|
110
|
+
mr: ["marginRight"],
|
|
111
|
+
mb: ["marginBottom"],
|
|
112
|
+
ml: ["marginLeft"],
|
|
113
|
+
gap: ["gap"],
|
|
114
|
+
"gap-x": ["columnGap"],
|
|
115
|
+
"gap-y": ["rowGap"],
|
|
116
|
+
top: ["top"],
|
|
117
|
+
right: ["right"],
|
|
118
|
+
bottom: ["bottom"],
|
|
119
|
+
left: ["left"],
|
|
120
|
+
inset: ["left", "right", "top", "bottom"],
|
|
121
|
+
"inset-x": ["left", "right"],
|
|
122
|
+
"inset-y": ["top", "bottom"],
|
|
123
|
+
basis: ["flexBasis"],
|
|
124
|
+
};
|
|
125
|
+
const warned = new Set();
|
|
126
|
+
function warnUnknown(cls) {
|
|
127
|
+
if (warned.has(cls))
|
|
128
|
+
return;
|
|
129
|
+
warned.add(cls);
|
|
130
|
+
console.warn(`[vsreact] unknown class "${cls}" ignored`);
|
|
131
|
+
}
|
|
132
|
+
/** "4"→16, "1.5"→6, "px"→1, "1/2"→"50%", "full"→"100%", "[123]"→123, "[45%]"→"45%" */
|
|
133
|
+
function parseLength(raw) {
|
|
134
|
+
if (raw.startsWith("[") && raw.endsWith("]")) {
|
|
135
|
+
const inner = raw.slice(1, -1);
|
|
136
|
+
if (inner.endsWith("%"))
|
|
137
|
+
return inner;
|
|
138
|
+
const n = Number(inner.replace(/px$/, ""));
|
|
139
|
+
return Number.isFinite(n) ? n : undefined;
|
|
140
|
+
}
|
|
141
|
+
if (raw === "px")
|
|
142
|
+
return 1;
|
|
143
|
+
if (raw === "full")
|
|
144
|
+
return "100%";
|
|
145
|
+
if (raw === "auto")
|
|
146
|
+
return "auto";
|
|
147
|
+
if (/^\d+\/\d+$/.test(raw)) {
|
|
148
|
+
const [num, den] = raw.split("/").map(Number);
|
|
149
|
+
return `${((num / den) * 100).toFixed(4).replace(/\.?0+$/, "")}%`;
|
|
150
|
+
}
|
|
151
|
+
const n = Number(raw);
|
|
152
|
+
return Number.isFinite(n) ? n * 4 : undefined;
|
|
153
|
+
}
|
|
154
|
+
function resolveClass(cls) {
|
|
155
|
+
const negative = cls.startsWith("-");
|
|
156
|
+
const body = negative ? cls.slice(1) : cls;
|
|
157
|
+
const negate = (v) => (negative && typeof v === "number" ? -v : v);
|
|
158
|
+
const known = staticClasses[body];
|
|
159
|
+
if (known && !negative)
|
|
160
|
+
return known;
|
|
161
|
+
// rounded, rounded-lg, rounded-t-lg, rounded-br-full, rounded-[10]
|
|
162
|
+
if (body === "rounded" || body.startsWith("rounded-")) {
|
|
163
|
+
const rest = body === "rounded" ? "" : body.slice("rounded-".length);
|
|
164
|
+
const parts = rest === "" ? [] : rest.split("-");
|
|
165
|
+
let corner = "";
|
|
166
|
+
let size = "";
|
|
167
|
+
if (parts.length === 1) {
|
|
168
|
+
if (parts[0] in radiusCorners)
|
|
169
|
+
corner = parts[0];
|
|
170
|
+
else
|
|
171
|
+
size = parts[0];
|
|
172
|
+
}
|
|
173
|
+
else if (parts.length === 2) {
|
|
174
|
+
[corner, size] = parts;
|
|
175
|
+
}
|
|
176
|
+
const corners = radiusCorners[corner];
|
|
177
|
+
const radius = size.startsWith("[") ? parseLength(size) : radiusSizes[size];
|
|
178
|
+
if (corners === undefined || radius === undefined)
|
|
179
|
+
return undefined;
|
|
180
|
+
if (corner === "")
|
|
181
|
+
return { borderRadius: radius };
|
|
182
|
+
const style = {};
|
|
183
|
+
for (const key of corners)
|
|
184
|
+
style[key] = radius;
|
|
185
|
+
return style;
|
|
186
|
+
}
|
|
187
|
+
const dash = body.indexOf("-");
|
|
188
|
+
if (dash <= 0)
|
|
189
|
+
return undefined;
|
|
190
|
+
// Longest matching length-utility prefix (handles gap-x-4 vs gap-4, min-w-*).
|
|
191
|
+
for (const prefix of Object.keys(lengthKeys).sort((a, b) => b.length - a.length)) {
|
|
192
|
+
if (body.startsWith(prefix + "-")) {
|
|
193
|
+
const value = parseLength(body.slice(prefix.length + 1));
|
|
194
|
+
if (value === undefined)
|
|
195
|
+
break;
|
|
196
|
+
const style = {};
|
|
197
|
+
for (const key of lengthKeys[prefix])
|
|
198
|
+
style[key] = negate(value);
|
|
199
|
+
return style;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const prefix = body.slice(0, dash);
|
|
203
|
+
const rest = body.slice(dash + 1);
|
|
204
|
+
switch (prefix) {
|
|
205
|
+
case "bg": {
|
|
206
|
+
const color = resolveColor(rest);
|
|
207
|
+
return color !== undefined ? { backgroundColor: color } : undefined;
|
|
208
|
+
}
|
|
209
|
+
case "text": {
|
|
210
|
+
if (rest in textSizes)
|
|
211
|
+
return { fontSize: textSizes[rest] };
|
|
212
|
+
const color = resolveColor(rest);
|
|
213
|
+
return color !== undefined ? { color } : undefined;
|
|
214
|
+
}
|
|
215
|
+
case "border": {
|
|
216
|
+
const n = Number(rest);
|
|
217
|
+
if (Number.isFinite(n))
|
|
218
|
+
return { borderWidth: n };
|
|
219
|
+
const color = resolveColor(rest);
|
|
220
|
+
return color !== undefined ? { borderColor: color } : undefined;
|
|
221
|
+
}
|
|
222
|
+
case "opacity": {
|
|
223
|
+
const n = Number(rest);
|
|
224
|
+
return Number.isFinite(n) ? { opacity: n / 100 } : undefined;
|
|
225
|
+
}
|
|
226
|
+
case "leading": {
|
|
227
|
+
const n = Number(rest);
|
|
228
|
+
return Number.isFinite(n) ? { lineHeight: n * 4 } : undefined;
|
|
229
|
+
}
|
|
230
|
+
case "flex": {
|
|
231
|
+
const n = Number(rest);
|
|
232
|
+
return Number.isFinite(n) ? { flex: n } : undefined;
|
|
233
|
+
}
|
|
234
|
+
default:
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const cache = new Map();
|
|
239
|
+
export function tw(classes) {
|
|
240
|
+
const cached = cache.get(classes);
|
|
241
|
+
if (cached)
|
|
242
|
+
return cached;
|
|
243
|
+
const result = { style: {} };
|
|
244
|
+
for (const cls of classes.split(/\s+/)) {
|
|
245
|
+
if (cls === "")
|
|
246
|
+
continue;
|
|
247
|
+
let bucket = "style";
|
|
248
|
+
let body = cls;
|
|
249
|
+
if (body.startsWith("hover:"))
|
|
250
|
+
[bucket, body] = ["hoverStyle", body.slice(6)];
|
|
251
|
+
else if (body.startsWith("active:"))
|
|
252
|
+
[bucket, body] = ["activeStyle", body.slice(7)];
|
|
253
|
+
else if (body.startsWith("focus:"))
|
|
254
|
+
[bucket, body] = ["focusStyle", body.slice(6)];
|
|
255
|
+
const resolved = resolveClass(body);
|
|
256
|
+
if (resolved === undefined) {
|
|
257
|
+
warnUnknown(cls);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
result[bucket] = Object.assign(result[bucket] ?? {}, resolved);
|
|
261
|
+
}
|
|
262
|
+
cache.set(classes, result);
|
|
263
|
+
return result;
|
|
264
|
+
}
|
package/package.json
CHANGED
|
@@ -1,29 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vsreact/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "Write React. Ship native VST. A React renderer for JUCE audio plugins — QuickJS + Yoga + juce::Graphics, no webview.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"react",
|
|
7
|
+
"juce",
|
|
8
|
+
"vst",
|
|
9
|
+
"vst3",
|
|
10
|
+
"audio-plugin",
|
|
11
|
+
"renderer",
|
|
12
|
+
"quickjs",
|
|
13
|
+
"yoga",
|
|
14
|
+
"native",
|
|
15
|
+
"daw"
|
|
16
|
+
],
|
|
4
17
|
"type": "module",
|
|
5
18
|
"license": "MIT",
|
|
19
|
+
"homepage": "https://vsreact.n9records.com",
|
|
20
|
+
"bugs": "https://github.com/N9RecordsTechnologiesIL/VSReacT/issues",
|
|
6
21
|
"repository": {
|
|
7
22
|
"type": "git",
|
|
8
23
|
"url": "https://github.com/N9RecordsTechnologiesIL/VSReacT.git",
|
|
9
24
|
"directory": "vsreact/js"
|
|
10
25
|
},
|
|
11
|
-
"main": "
|
|
26
|
+
"main": "dist/index.js",
|
|
27
|
+
"types": "dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"bun": "./src/index.ts",
|
|
32
|
+
"import": "./dist/index.js",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
12
36
|
"files": [
|
|
37
|
+
"dist",
|
|
13
38
|
"src",
|
|
14
39
|
"build.ts"
|
|
15
40
|
],
|
|
16
41
|
"scripts": {
|
|
17
42
|
"test": "bun test",
|
|
18
43
|
"typecheck": "tsc --noEmit",
|
|
19
|
-
"build": "bun run build.ts"
|
|
44
|
+
"build": "bun run build.ts",
|
|
45
|
+
"build:dist": "tsc -p tsconfig.build.json",
|
|
46
|
+
"prepublishOnly": "bun run build:dist"
|
|
20
47
|
},
|
|
21
48
|
"dependencies": {
|
|
49
|
+
"@types/react": "^18.3.3",
|
|
22
50
|
"react": "18.3.1",
|
|
23
51
|
"react-reconciler": "0.29.2"
|
|
24
52
|
},
|
|
25
53
|
"devDependencies": {
|
|
26
|
-
"@types/react": "^18.3.3",
|
|
27
54
|
"@types/react-reconciler": "0.28.8",
|
|
28
55
|
"bun-types": "^1.3.14",
|
|
29
56
|
"typescript": "^5.5.0"
|
package/src/animation.test.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { Easing, lerp } from "./animation";
|
|
2
|
+
import { Easing, lerp, springStep } from "./animation";
|
|
3
|
+
import { cx } from "./cx";
|
|
3
4
|
|
|
4
5
|
describe("animation", () => {
|
|
5
6
|
test("easing curves start at 0 and end at 1", () => {
|
|
@@ -20,3 +21,57 @@ describe("animation", () => {
|
|
|
20
21
|
expect(lerp(4, 4, 0.3)).toBe(4);
|
|
21
22
|
});
|
|
22
23
|
});
|
|
24
|
+
|
|
25
|
+
describe("springStep", () => {
|
|
26
|
+
const settle = (options = {}) => {
|
|
27
|
+
let p = 0;
|
|
28
|
+
let v = 0;
|
|
29
|
+
for (let i = 0; i < 600; i++) [p, v] = springStep(p, v, 1, options, 16);
|
|
30
|
+
return [p, v];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
test("converges to the target and comes to rest", () => {
|
|
34
|
+
const [p, v] = settle();
|
|
35
|
+
expect(p).toBeCloseTo(1, 3);
|
|
36
|
+
expect(Math.abs(v)).toBeLessThan(0.001);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("accelerates toward the target from rest", () => {
|
|
40
|
+
const [p1, v1] = springStep(0, 0, 1, {}, 16);
|
|
41
|
+
expect(v1).toBeGreaterThan(0);
|
|
42
|
+
expect(p1).toBeGreaterThan(0);
|
|
43
|
+
expect(p1).toBeLessThan(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("underdamped springs overshoot; overdamped don't", () => {
|
|
47
|
+
let p = 0;
|
|
48
|
+
let v = 0;
|
|
49
|
+
let peak = 0;
|
|
50
|
+
for (let i = 0; i < 600; i++) {
|
|
51
|
+
[p, v] = springStep(p, v, 1, { stiffness: 300, damping: 8 }, 16);
|
|
52
|
+
peak = Math.max(peak, p);
|
|
53
|
+
}
|
|
54
|
+
expect(peak).toBeGreaterThan(1.01);
|
|
55
|
+
|
|
56
|
+
p = 0;
|
|
57
|
+
v = 0;
|
|
58
|
+
peak = 0;
|
|
59
|
+
for (let i = 0; i < 600; i++) {
|
|
60
|
+
[p, v] = springStep(p, v, 1, { stiffness: 120, damping: 40 }, 16);
|
|
61
|
+
peak = Math.max(peak, p);
|
|
62
|
+
}
|
|
63
|
+
expect(peak).toBeLessThanOrEqual(1.001);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("cx", () => {
|
|
68
|
+
test("joins strings and skips falsy values", () => {
|
|
69
|
+
expect(cx("flex", false && "hidden", undefined, null, "", "gap-2")).toBe("flex gap-2");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("supports arrays and object maps", () => {
|
|
73
|
+
expect(cx(["flex", ["items-center"]], { "opacity-40": true, "cursor-pointer": false })).toBe(
|
|
74
|
+
"flex items-center opacity-40",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/animation.ts
CHANGED
|
@@ -68,3 +68,74 @@ export function useTween({ duration, delay = 0, easing = Easing.outCubic, onComp
|
|
|
68
68
|
export function lerp(from: number, to: number, t: number): number {
|
|
69
69
|
return from + (to - from) * t;
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
// ── springs ────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export interface SpringOptions {
|
|
75
|
+
/** Spring constant — higher snaps faster. Default 170. */
|
|
76
|
+
stiffness?: number;
|
|
77
|
+
/** Velocity drag — higher settles with less bounce. Default 24. */
|
|
78
|
+
damping?: number;
|
|
79
|
+
mass?: number;
|
|
80
|
+
/** Distance from target below which the spring snaps and stops. */
|
|
81
|
+
restDelta?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One semi-implicit Euler integration step; exported for tests and for
|
|
85
|
+
driving springs from your own loops. Returns [position, velocity]. */
|
|
86
|
+
export function springStep(
|
|
87
|
+
position: number,
|
|
88
|
+
velocity: number,
|
|
89
|
+
target: number,
|
|
90
|
+
{ stiffness = 170, damping = 24, mass = 1 }: SpringOptions,
|
|
91
|
+
dtMs: number,
|
|
92
|
+
): [number, number] {
|
|
93
|
+
const dt = dtMs / 1000;
|
|
94
|
+
const acceleration = (-stiffness * (position - target) - damping * velocity) / mass;
|
|
95
|
+
const v = velocity + acceleration * dt;
|
|
96
|
+
return [position + v * dt, v];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A value that springs toward `target` whenever it changes — for
|
|
101
|
+
* interactive motion where a fixed-duration tween feels wrong (toggle
|
|
102
|
+
* thumbs, drawers, meters chasing levels). Runs on the host timer like
|
|
103
|
+
* useTween; starts at rest on the initial target.
|
|
104
|
+
*/
|
|
105
|
+
export function useSpring(target: number, options: SpringOptions = {}): number {
|
|
106
|
+
const [value, setValue] = useState(target);
|
|
107
|
+
const state = useRef({ position: target, velocity: 0 });
|
|
108
|
+
const targetRef = useRef(target);
|
|
109
|
+
targetRef.current = target;
|
|
110
|
+
const optionsRef = useRef(options);
|
|
111
|
+
optionsRef.current = options;
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
const restDelta = optionsRef.current.restDelta ?? 0.001;
|
|
115
|
+
if (
|
|
116
|
+
Math.abs(state.current.position - targetRef.current) <= restDelta &&
|
|
117
|
+
Math.abs(state.current.velocity) <= restDelta
|
|
118
|
+
) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const id = setInterval(() => {
|
|
123
|
+
const s = state.current;
|
|
124
|
+
const [p, v] = springStep(s.position, s.velocity, targetRef.current, optionsRef.current, FRAME_MS);
|
|
125
|
+
s.position = p;
|
|
126
|
+
s.velocity = v;
|
|
127
|
+
|
|
128
|
+
if (Math.abs(p - targetRef.current) <= restDelta && Math.abs(v) <= restDelta) {
|
|
129
|
+
s.position = targetRef.current;
|
|
130
|
+
s.velocity = 0;
|
|
131
|
+
clearInterval(id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setValue(s.position);
|
|
135
|
+
}, FRAME_MS);
|
|
136
|
+
|
|
137
|
+
return () => clearInterval(id);
|
|
138
|
+
}, [target]);
|
|
139
|
+
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
const batches: unknown[][][] = [];
|
|
4
|
+
const nativeCalls: Array<{ name: string; args: any }> = [];
|
|
5
|
+
let paramGetResult: any = { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB" };
|
|
6
|
+
|
|
7
|
+
(globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
|
|
8
|
+
batches.push(JSON.parse(json));
|
|
9
|
+
};
|
|
10
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = (name: string, argsJson: string) => {
|
|
11
|
+
const args = JSON.parse(argsJson);
|
|
12
|
+
nativeCalls.push({ name, args });
|
|
13
|
+
if (name === "param:get") return JSON.stringify(paramGetResult);
|
|
14
|
+
return "null";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
import { render, unmount, Slider, Toggle, XYPad, Segmented, ParamSegmented, ParamToggle } from "./index";
|
|
18
|
+
|
|
19
|
+
const allOps = () => batches.flat();
|
|
20
|
+
const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
|
|
21
|
+
const dispatch = (msg: unknown) =>
|
|
22
|
+
(globalThis as Record<string, any>).__vsreact_dispatch(JSON.stringify(msg));
|
|
23
|
+
|
|
24
|
+
/** First node id whose registered listeners include `type`. */
|
|
25
|
+
const nodeWithListener = (type: string): number => {
|
|
26
|
+
const props: any = opsNamed("setProps").find((op: any) => op[2]?.listeners?.includes(type));
|
|
27
|
+
expect(props).toBeDefined();
|
|
28
|
+
return props[1];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
unmount();
|
|
33
|
+
batches.length = 0;
|
|
34
|
+
nativeCalls.length = 0;
|
|
35
|
+
paramGetResult = { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB" };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("vertical Slider", () => {
|
|
39
|
+
test("drag up increases the value; fill rises from the bottom", () => {
|
|
40
|
+
const seen: number[] = [];
|
|
41
|
+
render(<Slider vertical height={100} value={0.5} onChange={(v) => seen.push(v)} />);
|
|
42
|
+
|
|
43
|
+
const id = nodeWithListener("drag");
|
|
44
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
|
|
45
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 0, dy: -25, x: 0, y: -25 } });
|
|
46
|
+
expect(seen.at(-1)).toBeCloseTo(0.75);
|
|
47
|
+
|
|
48
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 0, dy: 80, x: 0, y: 80 } });
|
|
49
|
+
expect(seen.at(-1)).toBe(0); // clamped
|
|
50
|
+
|
|
51
|
+
// Fill height reflects the (still-controlled) 0.5 value.
|
|
52
|
+
const fill: any = opsNamed("setProps").find((op: any) => op[2]?.style?.height === 50);
|
|
53
|
+
expect(fill).toBeDefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("Toggle", () => {
|
|
58
|
+
test("click flips; disabled ignores clicks", () => {
|
|
59
|
+
const seen: boolean[] = [];
|
|
60
|
+
render(<Toggle on={false} onChange={(v) => seen.push(v)} />);
|
|
61
|
+
|
|
62
|
+
const id = nodeWithListener("click");
|
|
63
|
+
dispatch({ kind: "event", nodeId: id, type: "click" });
|
|
64
|
+
expect(seen).toEqual([true]);
|
|
65
|
+
|
|
66
|
+
unmount();
|
|
67
|
+
batches.length = 0;
|
|
68
|
+
render(<Toggle on disabled onChange={(v) => seen.push(v)} />);
|
|
69
|
+
expect(opsNamed("setProps").some((op: any) => op[2]?.listeners?.includes("click"))).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("ParamToggle reads the param and writes a begin/set/end gesture", () => {
|
|
73
|
+
paramGetResult = { value: 0, text: "Off", name: "Bypass", label: "" };
|
|
74
|
+
render(<ParamToggle paramId="bypass" />);
|
|
75
|
+
|
|
76
|
+
const id = nodeWithListener("click");
|
|
77
|
+
dispatch({ kind: "event", nodeId: id, type: "click" });
|
|
78
|
+
|
|
79
|
+
const names = nativeCalls.map((c) => c.name);
|
|
80
|
+
expect(names).toContain("param:begin");
|
|
81
|
+
expect(names).toContain("param:end");
|
|
82
|
+
const set = nativeCalls.find((c) => c.name === "param:set");
|
|
83
|
+
expect(set?.args).toEqual({ id: "bypass", value: 1 });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("XYPad", () => {
|
|
88
|
+
test("drag maps both axes: right = +x, up = +y, clamped", () => {
|
|
89
|
+
const seen: Array<[number, number]> = [];
|
|
90
|
+
render(
|
|
91
|
+
<XYPad x={0.5} y={0.5} width={100} height={100} onChange={(x, y) => seen.push([x, y])} />,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const id = nodeWithListener("drag");
|
|
95
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
|
|
96
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 25, dy: -25, x: 0, y: 0 } });
|
|
97
|
+
expect(seen.at(-1)?.[0]).toBeCloseTo(0.75);
|
|
98
|
+
expect(seen.at(-1)?.[1]).toBeCloseTo(0.75);
|
|
99
|
+
|
|
100
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 999, dy: 999, x: 0, y: 0 } });
|
|
101
|
+
expect(seen.at(-1)).toEqual([1, 0]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("Segmented", () => {
|
|
106
|
+
test("clicking a segment reports its index", () => {
|
|
107
|
+
const seen: number[] = [];
|
|
108
|
+
render(<Segmented options={["SINE", "SAW", "SQR"]} index={0} onChange={(i) => seen.push(i)} />);
|
|
109
|
+
|
|
110
|
+
const clickables = opsNamed("setProps").filter((op: any) =>
|
|
111
|
+
op[2]?.listeners?.includes("click"),
|
|
112
|
+
);
|
|
113
|
+
expect(clickables.length).toBe(3);
|
|
114
|
+
|
|
115
|
+
dispatch({ kind: "event", nodeId: (clickables[2] as any)[1], type: "click" });
|
|
116
|
+
expect(seen).toEqual([2]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("ParamSegmented maps value→index and writes index/(n−1)", () => {
|
|
120
|
+
paramGetResult = { value: 0.5, text: "Saw", name: "Shape", label: "" };
|
|
121
|
+
render(<ParamSegmented paramId="shape" options={["SINE", "SAW", "SQR"]} />);
|
|
122
|
+
|
|
123
|
+
const clickables = opsNamed("setProps").filter((op: any) =>
|
|
124
|
+
op[2]?.listeners?.includes("click"),
|
|
125
|
+
);
|
|
126
|
+
dispatch({ kind: "event", nodeId: (clickables[0] as any)[1], type: "click" });
|
|
127
|
+
|
|
128
|
+
const set = nativeCalls.find((c) => c.name === "param:set");
|
|
129
|
+
expect(set?.args).toEqual({ id: "shape", value: 0 });
|
|
130
|
+
});
|
|
131
|
+
});
|