@vsreact/core 0.0.10 → 0.0.12
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/build.ts +24 -24
- package/dist/components.d.ts +6 -0
- package/dist/components.js +3 -0
- package/dist/controls.d.ts +7 -2
- package/dist/controls.js +11 -11
- package/dist/feedback.d.ts +4 -1
- package/dist/feedback.js +12 -2
- package/dist/format.d.ts +16 -0
- package/dist/format.js +50 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/keyboard.d.ts +23 -0
- package/dist/keyboard.js +79 -0
- package/dist/sequencer.d.ts +23 -0
- package/dist/sequencer.js +20 -0
- package/dist/specialty.d.ts +109 -0
- package/dist/specialty.js +186 -0
- package/dist/tw.js +5 -0
- package/package.json +64 -64
- package/src/bridge.ts +66 -66
- package/src/components.ts +22 -0
- package/src/controls.test.tsx +109 -0
- package/src/controls.tsx +62 -22
- package/src/feedback.tsx +28 -6
- package/src/format.ts +62 -0
- package/src/hostConfig.test.tsx +120 -120
- package/src/hostConfig.ts +164 -164
- package/src/index.ts +31 -0
- package/src/keyboard.tsx +151 -0
- package/src/native.ts +18 -18
- package/src/perform.test.tsx +188 -0
- package/src/primitives.tsx +95 -95
- package/src/render.tsx +47 -47
- package/src/runtime.ts +92 -92
- package/src/sequencer.tsx +83 -0
- package/src/specialty.tsx +514 -0
- package/src/theme.ts +159 -159
- package/src/tw.test.ts +119 -113
- package/src/tw.ts +288 -284
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The flagship-visual tier — Output-style macro pads, hardware knobs,
|
|
3
|
+
// crossfader strips, and value-reactive orbs. Everything is painted
|
|
4
|
+
// natively from Views, arcs, and shadows; the motion runs on the host
|
|
5
|
+
// scheduler.
|
|
6
|
+
import { useRef, useState } from "react";
|
|
7
|
+
import { View, Text } from "./primitives";
|
|
8
|
+
import { useParameter } from "./parameters";
|
|
9
|
+
import { useInterval } from "./hooks";
|
|
10
|
+
const clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
11
|
+
/** The centerpiece macro control: a circular 2D pad whose concentric
|
|
12
|
+
rings breathe with the values — x spreads them, y drives their
|
|
13
|
+
intensity. One drag, two parameters, instrument-grade presence. */
|
|
14
|
+
export function MacroPad({ x, y, size = 220, labelX, labelY, disabled, rings = 9, animate = true, color = "#C6F135", trackColor = "#101210", onChange, onBegin, onEnd, }) {
|
|
15
|
+
const start = useRef({ x: 0, y: 0 });
|
|
16
|
+
const [phase, setPhase] = useState(0);
|
|
17
|
+
useInterval(() => setPhase((p) => (p + 1) % 1000), animate && !disabled ? 50 : null);
|
|
18
|
+
const cx = clamp01(x);
|
|
19
|
+
const cy = clamp01(y);
|
|
20
|
+
const thumb = 12;
|
|
21
|
+
const tx = cx * (size - thumb);
|
|
22
|
+
const ty = (1 - cy) * (size - thumb);
|
|
23
|
+
const ringViews = [];
|
|
24
|
+
for (let i = 0; i < rings; i++) {
|
|
25
|
+
const t = (i + 1) / rings;
|
|
26
|
+
// x spreads the rings outward; the phase makes them breathe gently.
|
|
27
|
+
const spread = 0.3 + 0.7 * Math.pow(t, 1.6 - cx * 1.2);
|
|
28
|
+
const breathe = 1 + 0.02 * Math.sin(phase / 6 + i * 0.9);
|
|
29
|
+
const ringSize = Math.min(size - 2, size * spread * breathe);
|
|
30
|
+
// y drives intensity; outer rings fade.
|
|
31
|
+
const opacity = clamp01((0.12 + 0.5 * cy) * (1.15 - t) * (0.8 + 0.2 * Math.sin(phase / 9 + i)));
|
|
32
|
+
ringViews.push(_jsx(View, { className: "absolute rounded-full border", style: {
|
|
33
|
+
width: ringSize,
|
|
34
|
+
height: ringSize,
|
|
35
|
+
left: (size - ringSize) / 2,
|
|
36
|
+
top: (size - ringSize) / 2,
|
|
37
|
+
borderColor: color,
|
|
38
|
+
opacity,
|
|
39
|
+
} }, i));
|
|
40
|
+
}
|
|
41
|
+
return (_jsxs(View, { className: `relative rounded-full overflow-hidden border ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width: size, height: size, backgroundColor: trackColor, borderColor: "#00000066" }, onDragStart: disabled
|
|
42
|
+
? undefined
|
|
43
|
+
: () => {
|
|
44
|
+
start.current = { x: cx, y: cy };
|
|
45
|
+
onBegin?.();
|
|
46
|
+
}, onDrag: disabled
|
|
47
|
+
? undefined
|
|
48
|
+
: (e) => onChange(clamp01(start.current.x + e.dx / size), clamp01(start.current.y - e.dy / size)), onDragEnd: disabled ? undefined : () => onEnd?.(), onDoubleClick: disabled ? undefined : () => onChange(0.5, 0.5), children: [ringViews, _jsx(View, { className: "absolute rounded-full", style: {
|
|
49
|
+
width: thumb,
|
|
50
|
+
height: thumb,
|
|
51
|
+
left: tx,
|
|
52
|
+
top: ty,
|
|
53
|
+
backgroundColor: color,
|
|
54
|
+
shadowColor: color,
|
|
55
|
+
shadowRadius: 10,
|
|
56
|
+
} }), labelY !== undefined ? (_jsx(Text, { className: "absolute text-[9] font-bold tracking-widest", style: { left: 10, top: size / 2 - 6, color, opacity: 0.75 }, children: labelY })) : null, labelX !== undefined ? (_jsx(Text, { className: "absolute text-[9] font-bold tracking-widest", style: { bottom: 10, left: size / 2 - 24, color, opacity: 0.75 }, children: labelX })) : null] }));
|
|
57
|
+
}
|
|
58
|
+
/** A MacroPad driving two host parameters — labels default to their
|
|
59
|
+
names. */
|
|
60
|
+
export function ParamMacroPad({ paramX, paramY, labelX, labelY, ...rest }) {
|
|
61
|
+
const px = useParameter(paramX);
|
|
62
|
+
const py = useParameter(paramY);
|
|
63
|
+
return (_jsx(MacroPad, { x: px.value, y: py.value, labelX: labelX ?? px.name.toUpperCase(), labelY: labelY ?? py.name.toUpperCase(), onChange: (nx, ny) => {
|
|
64
|
+
px.set(nx);
|
|
65
|
+
py.set(ny);
|
|
66
|
+
}, onBegin: () => {
|
|
67
|
+
px.begin();
|
|
68
|
+
py.begin();
|
|
69
|
+
}, onEnd: () => {
|
|
70
|
+
px.end();
|
|
71
|
+
py.end();
|
|
72
|
+
}, ...rest }));
|
|
73
|
+
}
|
|
74
|
+
// ── HardwareKnob ───────────────────────────────────────────────────────
|
|
75
|
+
const HW_START = -135;
|
|
76
|
+
const HW_END = 135;
|
|
77
|
+
/** The skeuomorphic knob: a dark hardware cap with a glowing pointer
|
|
78
|
+
notch at the rim and a faint tick track — the audio-ui.com look,
|
|
79
|
+
painted natively. */
|
|
80
|
+
export function HardwareKnob({ value, size = 88, label, text, disabled, defaultValue, wheelSensitivity = 0.4, capColor = "#1B1B1A", pointerColor = "#C6F135", tickColor = "#FFFFFF14", onChange, onBegin, onEnd, }) {
|
|
81
|
+
const startValue = useRef(0);
|
|
82
|
+
const clamped = clamp01(value);
|
|
83
|
+
const angle = HW_START + (HW_END - HW_START) * clamped;
|
|
84
|
+
const nudge = (target) => {
|
|
85
|
+
onBegin?.();
|
|
86
|
+
onChange(clamp01(target));
|
|
87
|
+
onEnd?.();
|
|
88
|
+
};
|
|
89
|
+
return (_jsxs(View, { className: "items-center gap-2", children: [_jsxs(View, { className: `relative ${disabled ? "opacity-40" : "cursor-pointer"}`, style: {
|
|
90
|
+
width: size,
|
|
91
|
+
height: size,
|
|
92
|
+
arcTrackColor: tickColor,
|
|
93
|
+
arcStart: HW_START,
|
|
94
|
+
arcEnd: HW_END,
|
|
95
|
+
arcThickness: 2,
|
|
96
|
+
}, onDragStart: disabled
|
|
97
|
+
? undefined
|
|
98
|
+
: () => {
|
|
99
|
+
startValue.current = clamped;
|
|
100
|
+
onBegin?.();
|
|
101
|
+
}, onDrag: disabled
|
|
102
|
+
? undefined
|
|
103
|
+
: (e) => onChange(clamp01(startValue.current - e.dy * 0.005)), onDragEnd: disabled ? undefined : () => onEnd?.(), onDoubleClick: disabled || defaultValue === undefined ? undefined : () => nudge(defaultValue), onWheel: disabled || wheelSensitivity === 0
|
|
104
|
+
? undefined
|
|
105
|
+
: (e) => nudge(clamped + e.dy * wheelSensitivity), children: [_jsx(View, { className: "absolute rounded-full border items-center justify-center shadow-lg", style: {
|
|
106
|
+
left: size * 0.09,
|
|
107
|
+
top: size * 0.09,
|
|
108
|
+
width: size * 0.82,
|
|
109
|
+
height: size * 0.82,
|
|
110
|
+
backgroundColor: capColor,
|
|
111
|
+
borderColor: "#000000AA",
|
|
112
|
+
}, children: text !== undefined ? (_jsx(Text, { className: "text-text font-bold", style: { fontSize: Math.max(10, size * 0.14) }, children: text })) : null }), _jsx(View, { className: "absolute inset-0", style: {
|
|
113
|
+
arcColor: pointerColor,
|
|
114
|
+
arcStart: HW_START,
|
|
115
|
+
arcEnd: HW_END,
|
|
116
|
+
arcValueStart: angle - 4,
|
|
117
|
+
arcValueEnd: angle + 4,
|
|
118
|
+
arcThickness: Math.max(4, size * 0.075),
|
|
119
|
+
} })] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
|
|
120
|
+
}
|
|
121
|
+
/** A HardwareKnob bound to a host parameter. */
|
|
122
|
+
export function ParamHardwareKnob({ paramId, label, ...rest }) {
|
|
123
|
+
const param = useParameter(paramId);
|
|
124
|
+
return (_jsx(HardwareKnob, { value: param.value, text: param.text, label: label ?? param.name.toUpperCase(), defaultValue: param.defaultValue, onChange: param.set, onBegin: param.begin, onEnd: param.end, ...rest }));
|
|
125
|
+
}
|
|
126
|
+
/** The DRY/WET strip: a wide track with a grippy rectangular handle. */
|
|
127
|
+
export function Crossfader({ value, width = 220, height = 34, labelStart = "DRY", labelEnd = "WET", disabled, trackColor = "#141614", handleColor = "#2E332C", textColor = "#6f6e66", onChange, onBegin, onEnd, }) {
|
|
128
|
+
const startValue = useRef(0);
|
|
129
|
+
const clamped = clamp01(value);
|
|
130
|
+
const handleWidth = 26;
|
|
131
|
+
const travel = width - handleWidth - 6;
|
|
132
|
+
return (_jsxs(View, { className: `relative rounded-lg border justify-center ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width, height, backgroundColor: trackColor, borderColor: "#00000066" }, onDragStart: disabled
|
|
133
|
+
? undefined
|
|
134
|
+
: () => {
|
|
135
|
+
startValue.current = clamped;
|
|
136
|
+
onBegin?.();
|
|
137
|
+
}, onDrag: disabled ? undefined : (e) => onChange(clamp01(startValue.current + e.dx / travel)), onDragEnd: disabled ? undefined : () => onEnd?.(), onDoubleClick: disabled ? undefined : () => {
|
|
138
|
+
onBegin?.();
|
|
139
|
+
onChange(0.5);
|
|
140
|
+
onEnd?.();
|
|
141
|
+
}, children: [_jsx(Text, { className: "absolute text-[8] font-bold tracking-widest", style: { left: 7, top: height / 2 - 5, color: textColor }, children: labelStart }), _jsx(Text, { className: "absolute text-[8] font-bold tracking-widest", style: { right: 7, top: height / 2 - 5, color: textColor }, children: labelEnd }), _jsxs(View, { className: "absolute rounded-md border flex-row items-center justify-center gap-[3]", style: {
|
|
142
|
+
left: 3 + clamped * travel,
|
|
143
|
+
top: 3,
|
|
144
|
+
bottom: 3,
|
|
145
|
+
width: handleWidth,
|
|
146
|
+
backgroundColor: handleColor,
|
|
147
|
+
borderColor: "#00000088",
|
|
148
|
+
}, children: [_jsx(View, { className: "w-[2] h-[12] rounded-full", style: { backgroundColor: "#00000066" } }), _jsx(View, { className: "w-[2] h-[12] rounded-full", style: { backgroundColor: "#00000066" } })] })] }));
|
|
149
|
+
}
|
|
150
|
+
/** A Crossfader bound to a host parameter — the classic mix control. */
|
|
151
|
+
export function ParamCrossfader({ paramId, ...rest }) {
|
|
152
|
+
const param = useParameter(paramId);
|
|
153
|
+
return (_jsx(Crossfader, { value: param.value, onChange: param.set, onBegin: param.begin, onEnd: param.end, ...rest }));
|
|
154
|
+
}
|
|
155
|
+
/** A value-reactive orb: a glowing core emitting echo rings — visual
|
|
156
|
+
feedback for levels, activity, or just presence. */
|
|
157
|
+
export function PulseOrb({ value, size = 120, color = "#C6F135", rings = 4, animate = true, }) {
|
|
158
|
+
const [phase, setPhase] = useState(0);
|
|
159
|
+
useInterval(() => setPhase((p) => (p + 1) % 1000), animate ? 40 : null);
|
|
160
|
+
const level = clamp01(value);
|
|
161
|
+
const core = 14 + level * 12;
|
|
162
|
+
const echoes = [];
|
|
163
|
+
for (let i = 0; i < rings; i++) {
|
|
164
|
+
const t = ((phase * (1 + level * 2) + (i * 100) / rings) % 100) / 100;
|
|
165
|
+
const ringSize = core + t * (size - core - 4);
|
|
166
|
+
const opacity = clamp01((1 - t) * (0.15 + 0.6 * level));
|
|
167
|
+
echoes.push(_jsx(View, { className: "absolute rounded-full border", style: {
|
|
168
|
+
width: ringSize,
|
|
169
|
+
height: ringSize,
|
|
170
|
+
left: (size - ringSize) / 2,
|
|
171
|
+
top: (size - ringSize) / 2,
|
|
172
|
+
borderColor: color,
|
|
173
|
+
opacity,
|
|
174
|
+
} }, i));
|
|
175
|
+
}
|
|
176
|
+
return (_jsxs(View, { className: "relative", style: { width: size, height: size }, children: [echoes, _jsx(View, { className: "absolute rounded-full", style: {
|
|
177
|
+
width: core,
|
|
178
|
+
height: core,
|
|
179
|
+
left: (size - core) / 2,
|
|
180
|
+
top: (size - core) / 2,
|
|
181
|
+
backgroundColor: color,
|
|
182
|
+
shadowColor: color,
|
|
183
|
+
shadowRadius: 6 + level * 14,
|
|
184
|
+
opacity: 0.55 + 0.45 * level,
|
|
185
|
+
} })] }));
|
|
186
|
+
}
|
package/dist/tw.js
CHANGED
|
@@ -217,6 +217,11 @@ function resolveClass(cls) {
|
|
|
217
217
|
case "text": {
|
|
218
218
|
if (rest in textSizes)
|
|
219
219
|
return { fontSize: textSizes[rest] };
|
|
220
|
+
if (rest.startsWith("[") && !rest.startsWith("[#")) {
|
|
221
|
+
const size = parseLength(rest);
|
|
222
|
+
if (typeof size === "number")
|
|
223
|
+
return { fontSize: size };
|
|
224
|
+
}
|
|
220
225
|
const color = resolveColor(rest);
|
|
221
226
|
return color !== undefined ? { color } : undefined;
|
|
222
227
|
}
|
package/package.json
CHANGED
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@vsreact/core",
|
|
3
|
-
"version": "0.0.
|
|
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
|
-
],
|
|
17
|
-
"type": "module",
|
|
18
|
-
"license": "MIT",
|
|
19
|
-
"homepage": "https://vsreact.n9records.com",
|
|
20
|
-
"bugs": "https://github.com/N9RecordsTechnologiesIL/VSReacT/issues",
|
|
21
|
-
"repository": {
|
|
22
|
-
"type": "git",
|
|
23
|
-
"url": "git+https://github.com/N9RecordsTechnologiesIL/VSReacT.git",
|
|
24
|
-
"directory": "vsreact/js"
|
|
25
|
-
},
|
|
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
|
-
"./components": {
|
|
36
|
-
"types": "./dist/components.d.ts",
|
|
37
|
-
"bun": "./src/components.ts",
|
|
38
|
-
"import": "./dist/components.js",
|
|
39
|
-
"default": "./dist/components.js"
|
|
40
|
-
}
|
|
41
|
-
},
|
|
42
|
-
"files": [
|
|
43
|
-
"dist",
|
|
44
|
-
"src",
|
|
45
|
-
"build.ts"
|
|
46
|
-
],
|
|
47
|
-
"scripts": {
|
|
48
|
-
"test": "bun test",
|
|
49
|
-
"typecheck": "tsc --noEmit",
|
|
50
|
-
"build": "bun run build.ts",
|
|
51
|
-
"build:dist": "tsc -p tsconfig.build.json",
|
|
52
|
-
"prepublishOnly": "bun run build:dist"
|
|
53
|
-
},
|
|
54
|
-
"dependencies": {
|
|
55
|
-
"@types/react": "^18.3.3",
|
|
56
|
-
"react": "18.3.1",
|
|
57
|
-
"react-reconciler": "0.29.2"
|
|
58
|
-
},
|
|
59
|
-
"devDependencies": {
|
|
60
|
-
"@types/react-reconciler": "0.28.8",
|
|
61
|
-
"bun-types": "^1.3.14",
|
|
62
|
-
"typescript": "^5.5.0"
|
|
63
|
-
}
|
|
64
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@vsreact/core",
|
|
3
|
+
"version": "0.0.12",
|
|
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
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"homepage": "https://vsreact.n9records.com",
|
|
20
|
+
"bugs": "https://github.com/N9RecordsTechnologiesIL/VSReacT/issues",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/N9RecordsTechnologiesIL/VSReacT.git",
|
|
24
|
+
"directory": "vsreact/js"
|
|
25
|
+
},
|
|
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
|
+
"./components": {
|
|
36
|
+
"types": "./dist/components.d.ts",
|
|
37
|
+
"bun": "./src/components.ts",
|
|
38
|
+
"import": "./dist/components.js",
|
|
39
|
+
"default": "./dist/components.js"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"dist",
|
|
44
|
+
"src",
|
|
45
|
+
"build.ts"
|
|
46
|
+
],
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "bun test",
|
|
49
|
+
"typecheck": "tsc --noEmit",
|
|
50
|
+
"build": "bun run build.ts",
|
|
51
|
+
"build:dist": "tsc -p tsconfig.build.json",
|
|
52
|
+
"prepublishOnly": "bun run build:dist"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@types/react": "^18.3.3",
|
|
56
|
+
"react": "18.3.1",
|
|
57
|
+
"react-reconciler": "0.29.2"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/react-reconciler": "0.28.8",
|
|
61
|
+
"bun-types": "^1.3.14",
|
|
62
|
+
"typescript": "^5.5.0"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/bridge.ts
CHANGED
|
@@ -1,66 +1,66 @@
|
|
|
1
|
-
// JS side of the mutation/event bridge. Ops queue up during a React commit
|
|
2
|
-
// and flush to C++ as one JSON batch; C++ pushes events back through the
|
|
3
|
-
// __vsreact_dispatch global registered here.
|
|
4
|
-
|
|
5
|
-
import { fireTimer } from "./runtime";
|
|
6
|
-
|
|
7
|
-
export type Op = unknown[];
|
|
8
|
-
|
|
9
|
-
let queue: Op[] = [];
|
|
10
|
-
|
|
11
|
-
export function queueOp(op: Op): void {
|
|
12
|
-
queue.push(op);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function flushOps(): void {
|
|
16
|
-
if (queue.length === 0) return;
|
|
17
|
-
const json = JSON.stringify(queue);
|
|
18
|
-
queue = [];
|
|
19
|
-
(globalThis as Record<string, any>).__vsreact_flush?.(json);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export type EventHandler = (payload: unknown) => void;
|
|
23
|
-
|
|
24
|
-
const eventHandlers = new Map<number, Map<string, EventHandler>>();
|
|
25
|
-
|
|
26
|
-
export function setHandlers(nodeId: number, handlers: Map<string, EventHandler>): void {
|
|
27
|
-
if (handlers.size === 0) eventHandlers.delete(nodeId);
|
|
28
|
-
else eventHandlers.set(nodeId, handlers);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function removeHandlers(nodeId: number): void {
|
|
32
|
-
eventHandlers.delete(nodeId);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const nativeListeners = new Map<string, Set<EventHandler>>();
|
|
36
|
-
|
|
37
|
-
export function addNativeListener(name: string, cb: EventHandler): () => void {
|
|
38
|
-
let set = nativeListeners.get(name);
|
|
39
|
-
if (!set) nativeListeners.set(name, (set = new Set()));
|
|
40
|
-
set.add(cb);
|
|
41
|
-
return () => {
|
|
42
|
-
set.delete(cb);
|
|
43
|
-
if (set.size === 0) nativeListeners.delete(name);
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
interface DispatchMessage {
|
|
48
|
-
kind: "event" | "native" | "timer";
|
|
49
|
-
nodeId?: number;
|
|
50
|
-
type?: string;
|
|
51
|
-
name?: string;
|
|
52
|
-
id?: number;
|
|
53
|
-
payload?: unknown;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
(globalThis as Record<string, any>).__vsreact_dispatch = (json: string) => {
|
|
57
|
-
const msg = JSON.parse(json) as DispatchMessage;
|
|
58
|
-
|
|
59
|
-
if (msg.kind === "timer" && msg.id !== undefined) {
|
|
60
|
-
fireTimer(msg.id);
|
|
61
|
-
} else if (msg.kind === "event" && msg.nodeId !== undefined && msg.type) {
|
|
62
|
-
eventHandlers.get(msg.nodeId)?.get(msg.type)?.(msg.payload);
|
|
63
|
-
} else if (msg.kind === "native" && msg.name) {
|
|
64
|
-
nativeListeners.get(msg.name)?.forEach((cb) => cb(msg.payload));
|
|
65
|
-
}
|
|
66
|
-
};
|
|
1
|
+
// JS side of the mutation/event bridge. Ops queue up during a React commit
|
|
2
|
+
// and flush to C++ as one JSON batch; C++ pushes events back through the
|
|
3
|
+
// __vsreact_dispatch global registered here.
|
|
4
|
+
|
|
5
|
+
import { fireTimer } from "./runtime";
|
|
6
|
+
|
|
7
|
+
export type Op = unknown[];
|
|
8
|
+
|
|
9
|
+
let queue: Op[] = [];
|
|
10
|
+
|
|
11
|
+
export function queueOp(op: Op): void {
|
|
12
|
+
queue.push(op);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function flushOps(): void {
|
|
16
|
+
if (queue.length === 0) return;
|
|
17
|
+
const json = JSON.stringify(queue);
|
|
18
|
+
queue = [];
|
|
19
|
+
(globalThis as Record<string, any>).__vsreact_flush?.(json);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type EventHandler = (payload: unknown) => void;
|
|
23
|
+
|
|
24
|
+
const eventHandlers = new Map<number, Map<string, EventHandler>>();
|
|
25
|
+
|
|
26
|
+
export function setHandlers(nodeId: number, handlers: Map<string, EventHandler>): void {
|
|
27
|
+
if (handlers.size === 0) eventHandlers.delete(nodeId);
|
|
28
|
+
else eventHandlers.set(nodeId, handlers);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function removeHandlers(nodeId: number): void {
|
|
32
|
+
eventHandlers.delete(nodeId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const nativeListeners = new Map<string, Set<EventHandler>>();
|
|
36
|
+
|
|
37
|
+
export function addNativeListener(name: string, cb: EventHandler): () => void {
|
|
38
|
+
let set = nativeListeners.get(name);
|
|
39
|
+
if (!set) nativeListeners.set(name, (set = new Set()));
|
|
40
|
+
set.add(cb);
|
|
41
|
+
return () => {
|
|
42
|
+
set.delete(cb);
|
|
43
|
+
if (set.size === 0) nativeListeners.delete(name);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface DispatchMessage {
|
|
48
|
+
kind: "event" | "native" | "timer";
|
|
49
|
+
nodeId?: number;
|
|
50
|
+
type?: string;
|
|
51
|
+
name?: string;
|
|
52
|
+
id?: number;
|
|
53
|
+
payload?: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
(globalThis as Record<string, any>).__vsreact_dispatch = (json: string) => {
|
|
57
|
+
const msg = JSON.parse(json) as DispatchMessage;
|
|
58
|
+
|
|
59
|
+
if (msg.kind === "timer" && msg.id !== undefined) {
|
|
60
|
+
fireTimer(msg.id);
|
|
61
|
+
} else if (msg.kind === "event" && msg.nodeId !== undefined && msg.type) {
|
|
62
|
+
eventHandlers.get(msg.nodeId)?.get(msg.type)?.(msg.payload);
|
|
63
|
+
} else if (msg.kind === "native" && msg.name) {
|
|
64
|
+
nativeListeners.get(msg.name)?.forEach((cb) => cb(msg.payload));
|
|
65
|
+
}
|
|
66
|
+
};
|
package/src/components.ts
CHANGED
|
@@ -64,3 +64,25 @@ export type {
|
|
|
64
64
|
} from "./fields";
|
|
65
65
|
export { ProgressBar, Spinner } from "./feedback";
|
|
66
66
|
export type { ProgressBarProps, SpinnerProps } from "./feedback";
|
|
67
|
+
export { PianoKeyboard } from "./keyboard";
|
|
68
|
+
export type { PianoKeyboardProps } from "./keyboard";
|
|
69
|
+
export { StepSequencer } from "./sequencer";
|
|
70
|
+
export type { StepSequencerProps } from "./sequencer";
|
|
71
|
+
export {
|
|
72
|
+
MacroPad,
|
|
73
|
+
ParamMacroPad,
|
|
74
|
+
HardwareKnob,
|
|
75
|
+
ParamHardwareKnob,
|
|
76
|
+
Crossfader,
|
|
77
|
+
ParamCrossfader,
|
|
78
|
+
PulseOrb,
|
|
79
|
+
} from "./specialty";
|
|
80
|
+
export type {
|
|
81
|
+
MacroPadProps,
|
|
82
|
+
ParamMacroPadProps,
|
|
83
|
+
HardwareKnobProps,
|
|
84
|
+
ParamHardwareKnobProps,
|
|
85
|
+
CrossfaderProps,
|
|
86
|
+
ParamCrossfaderProps,
|
|
87
|
+
PulseOrbProps,
|
|
88
|
+
} from "./specialty";
|
package/src/controls.test.tsx
CHANGED
|
@@ -664,3 +664,112 @@ describe("0.0.10 — fields & feedback", () => {
|
|
|
664
664
|
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "GAIN")).toBe(true);
|
|
665
665
|
});
|
|
666
666
|
});
|
|
667
|
+
|
|
668
|
+
describe("0.0.11 — flagship visuals", () => {
|
|
669
|
+
test("MacroPad: drag maps both axes, double-click recenters, rings render", () => {
|
|
670
|
+
const { MacroPad } = require("./index");
|
|
671
|
+
const seen: Array<[number, number]> = [];
|
|
672
|
+
render(
|
|
673
|
+
<MacroPad x={0.5} y={0.5} size={200} animate={false} rings={6} onChange={(x: number, y: number) => seen.push([x, y])} />,
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const id = nodeWithListener("drag");
|
|
677
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
|
|
678
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 50, dy: -50, x: 0, y: 0 } });
|
|
679
|
+
expect(seen.at(-1)?.[0]).toBeCloseTo(0.75);
|
|
680
|
+
expect(seen.at(-1)?.[1]).toBeCloseTo(0.75);
|
|
681
|
+
|
|
682
|
+
dispatch({ kind: "event", nodeId: id, type: "dblclick" });
|
|
683
|
+
expect(seen.at(-1)).toEqual([0.5, 0.5]);
|
|
684
|
+
|
|
685
|
+
// six rings + thumb: ring views carry borderColor + opacity
|
|
686
|
+
const rings = opsNamed("setProps").filter(
|
|
687
|
+
(op: any) => op[2]?.style?.borderColor === "#C6F135" && op[2]?.style?.opacity !== undefined,
|
|
688
|
+
);
|
|
689
|
+
expect(rings.length).toBeGreaterThanOrEqual(6);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("ParamMacroPad opens and closes both gestures", () => {
|
|
693
|
+
const { ParamMacroPad } = require("./index");
|
|
694
|
+
render(<ParamMacroPad paramX="cutoff" paramY="res" animate={false} />);
|
|
695
|
+
|
|
696
|
+
const id = nodeWithListener("drag");
|
|
697
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
|
|
698
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 20, dy: 0, x: 0, y: 0 } });
|
|
699
|
+
dispatch({ kind: "event", nodeId: id, type: "dragend", payload: { dx: 20, dy: 0, x: 0, y: 0 } });
|
|
700
|
+
|
|
701
|
+
expect(nativeCalls.filter((c) => c.name === "param:begin").length).toBe(2);
|
|
702
|
+
expect(nativeCalls.filter((c) => c.name === "param:end").length).toBe(2);
|
|
703
|
+
expect(nativeCalls.filter((c) => c.name === "param:set").length).toBe(2);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test("HardwareKnob: pointer notch tracks the angle; DAW gestures work", () => {
|
|
707
|
+
const { HardwareKnob } = require("./index");
|
|
708
|
+
const seen: number[] = [];
|
|
709
|
+
render(<HardwareKnob value={0.5} defaultValue={0.25} onChange={(v: number) => seen.push(v)} />);
|
|
710
|
+
|
|
711
|
+
// value 0.5 -> angle 0 -> notch arc -4..+4
|
|
712
|
+
const notch: any = opsNamed("setProps").find(
|
|
713
|
+
(op: any) => op[2]?.style?.arcValueStart === -4 && op[2]?.style?.arcValueEnd === 4,
|
|
714
|
+
);
|
|
715
|
+
expect(notch).toBeDefined();
|
|
716
|
+
|
|
717
|
+
const id = nodeWithListener("dblclick");
|
|
718
|
+
dispatch({ kind: "event", nodeId: id, type: "dblclick" });
|
|
719
|
+
expect(seen.at(-1)).toBe(0.25);
|
|
720
|
+
|
|
721
|
+
dispatch({ kind: "event", nodeId: id, type: "wheel", payload: { dy: 0.1 } });
|
|
722
|
+
expect(seen.at(-1)).toBeCloseTo(0.54);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("Crossfader: horizontal drag with travel compensation, labels render", () => {
|
|
726
|
+
const { Crossfader } = require("./index");
|
|
727
|
+
const seen: number[] = [];
|
|
728
|
+
render(<Crossfader value={0.5} width={220} onChange={(v: number) => seen.push(v)} />);
|
|
729
|
+
|
|
730
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "DRY")).toBe(true);
|
|
731
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "WET")).toBe(true);
|
|
732
|
+
|
|
733
|
+
const id = nodeWithListener("drag");
|
|
734
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
|
|
735
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 94, dy: 0, x: 0, y: 0 } }); // travel = 220-26-6 = 188
|
|
736
|
+
expect(seen.at(-1)).toBeCloseTo(1);
|
|
737
|
+
|
|
738
|
+
dispatch({ kind: "event", nodeId: id, type: "dblclick" });
|
|
739
|
+
expect(seen.at(-1)).toBe(0.5);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test("PulseOrb: echo rings advance with the phase", async () => {
|
|
743
|
+
const { PulseOrb } = require("./index");
|
|
744
|
+
render(<PulseOrb value={0.8} size={100} rings={3} />);
|
|
745
|
+
|
|
746
|
+
const sizesAt = () =>
|
|
747
|
+
opsNamed("setProps")
|
|
748
|
+
.filter((op: any) => op[2]?.style?.borderColor === "#C6F135")
|
|
749
|
+
.map((op: any) => op[2].style.width);
|
|
750
|
+
const before = sizesAt().length;
|
|
751
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
752
|
+
expect(sizesAt().length).toBeGreaterThan(before); // new frames landed
|
|
753
|
+
|
|
754
|
+
// the core glows with the level
|
|
755
|
+
const core: any = opsNamed("setProps").find((op: any) => op[2]?.style?.shadowColor === "#C6F135");
|
|
756
|
+
expect(core).toBeDefined();
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("Toggle side labels highlight the active side; Slider barThumb renders a bar", () => {
|
|
760
|
+
const { Toggle: T2, Slider: S2 } = require("./index");
|
|
761
|
+
render(<T2 on={false} offLabel="OFF" onLabel="ON" onChange={() => {}} />);
|
|
762
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "OFF")).toBe(true);
|
|
763
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "ON")).toBe(true);
|
|
764
|
+
|
|
765
|
+
unmount();
|
|
766
|
+
batches.length = 0;
|
|
767
|
+
render(<S2 vertical barThumb height={100} value={0.5} onChange={() => {}} />);
|
|
768
|
+
// bar thumb: 18 wide, 5 tall at the halfway point
|
|
769
|
+
const bar: any = opsNamed("setProps").find(
|
|
770
|
+
(op: any) => op[2]?.style?.width === 18 && op[2]?.style?.height === 5,
|
|
771
|
+
);
|
|
772
|
+
expect(bar).toBeDefined();
|
|
773
|
+
expect(bar[2].style.top).toBeCloseTo(0.5 * 95);
|
|
774
|
+
});
|
|
775
|
+
});
|