@vsreact/core 0.0.4 → 0.0.6
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/controls.d.ts +57 -6
- package/dist/controls.js +79 -14
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.js +10 -0
- package/dist/hostConfig.js +3 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.js +4 -2
- package/dist/overlay.d.ts +9 -0
- package/dist/overlay.js +47 -0
- package/dist/parameters.d.ts +3 -0
- package/dist/parameters.js +3 -1
- package/dist/popover.d.ts +27 -0
- package/dist/popover.js +55 -0
- package/dist/primitives.d.ts +21 -0
- package/dist/render.d.ts +2 -1
- package/dist/render.js +5 -2
- package/package.json +2 -2
- package/src/controls.test.tsx +243 -1
- package/src/controls.tsx +216 -13
- package/src/hooks.ts +12 -0
- package/src/hostConfig.ts +3 -0
- package/src/index.ts +9 -2
- package/src/overlay.tsx +70 -0
- package/src/parameters.ts +6 -1
- package/src/popover.tsx +137 -0
- package/src/primitives.tsx +20 -0
- package/src/render.tsx +12 -2
package/src/controls.test.tsx
CHANGED
|
@@ -7,12 +7,13 @@ let paramGetResult: any = { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB
|
|
|
7
7
|
(globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
|
|
8
8
|
batches.push(JSON.parse(json));
|
|
9
9
|
};
|
|
10
|
-
|
|
10
|
+
const defaultNativeCall = (name: string, argsJson: string) => {
|
|
11
11
|
const args = JSON.parse(argsJson);
|
|
12
12
|
nativeCalls.push({ name, args });
|
|
13
13
|
if (name === "param:get") return JSON.stringify(paramGetResult);
|
|
14
14
|
return "null";
|
|
15
15
|
};
|
|
16
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = defaultNativeCall;
|
|
16
17
|
|
|
17
18
|
import { render, unmount, Slider, Toggle, XYPad, Segmented, ParamSegmented, ParamToggle } from "./index";
|
|
18
19
|
|
|
@@ -33,6 +34,7 @@ beforeEach(() => {
|
|
|
33
34
|
batches.length = 0;
|
|
34
35
|
nativeCalls.length = 0;
|
|
35
36
|
paramGetResult = { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB" };
|
|
37
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = defaultNativeCall;
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
describe("vertical Slider", () => {
|
|
@@ -191,3 +193,243 @@ describe("Meter", () => {
|
|
|
191
193
|
expect(hot).toBeUndefined();
|
|
192
194
|
});
|
|
193
195
|
});
|
|
196
|
+
|
|
197
|
+
describe("onLayout + Select", () => {
|
|
198
|
+
/** Map child → parent from appendChild ops, to find a row by its text. */
|
|
199
|
+
const parentMap = () => {
|
|
200
|
+
const map = new Map<number, number>();
|
|
201
|
+
for (const op of allOps() as any[])
|
|
202
|
+
if (op[0] === "appendChild" || op[0] === "insertBefore") map.set(op[2], op[1]);
|
|
203
|
+
return map;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
test("onLayout registers a layout listener and receives the rect", () => {
|
|
207
|
+
const seen: any[] = [];
|
|
208
|
+
render(<Slider value={0.5} onChange={() => {}} />); // warm-up unrelated tree
|
|
209
|
+
unmount();
|
|
210
|
+
batches.length = 0;
|
|
211
|
+
|
|
212
|
+
const { View: V } = require("./index");
|
|
213
|
+
render(<V onLayout={(r: any) => seen.push(r)} className="w-4" />);
|
|
214
|
+
|
|
215
|
+
const props: any = opsNamed("setProps").find((op: any) =>
|
|
216
|
+
op[2]?.listeners?.includes("layout"),
|
|
217
|
+
);
|
|
218
|
+
expect(props).toBeDefined();
|
|
219
|
+
|
|
220
|
+
dispatch({
|
|
221
|
+
kind: "event",
|
|
222
|
+
nodeId: props[1],
|
|
223
|
+
type: "layout",
|
|
224
|
+
payload: { x: 5, y: 10, width: 160, height: 32 },
|
|
225
|
+
});
|
|
226
|
+
expect(seen).toEqual([{ x: 5, y: 10, width: 160, height: 32 }]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("Select opens a positioned overlay menu and selects on click", async () => {
|
|
230
|
+
const { Select } = require("./index");
|
|
231
|
+
const seen: number[] = [];
|
|
232
|
+
render(
|
|
233
|
+
<Select options={["SINE", "SAW", "SQR"]} index={0} width={160} onChange={(i: number) => seen.push(i)} />,
|
|
234
|
+
);
|
|
235
|
+
await new Promise((r) => setTimeout(r, 0)); // let effects flush
|
|
236
|
+
|
|
237
|
+
// trigger has both layout + click listeners
|
|
238
|
+
const trigger: any = opsNamed("setProps").find(
|
|
239
|
+
(op: any) => op[2]?.listeners?.includes("layout") && op[2]?.listeners?.includes("click"),
|
|
240
|
+
);
|
|
241
|
+
expect(trigger).toBeDefined();
|
|
242
|
+
|
|
243
|
+
// layout arrives, then the click opens the menu
|
|
244
|
+
dispatch({
|
|
245
|
+
kind: "event",
|
|
246
|
+
nodeId: trigger[1],
|
|
247
|
+
type: "layout",
|
|
248
|
+
payload: { x: 20, y: 40, width: 160, height: 33 },
|
|
249
|
+
});
|
|
250
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
251
|
+
dispatch({ kind: "event", nodeId: trigger[1], type: "click" });
|
|
252
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
253
|
+
|
|
254
|
+
// menu panel positioned under the trigger, same width
|
|
255
|
+
const panel: any = opsNamed("setProps").find(
|
|
256
|
+
(op: any) => op[2]?.style?.top === 40 + 33 + 4 && op[2]?.style?.left === 20,
|
|
257
|
+
);
|
|
258
|
+
expect(panel).toBeDefined();
|
|
259
|
+
expect(panel[2].style.width).toBe(160);
|
|
260
|
+
|
|
261
|
+
// find the "SAW" row: rawtext -> Text -> row
|
|
262
|
+
const sawText: any = allOps().find((op: any) => op[0] === "setText" && op[2] === "SAW");
|
|
263
|
+
expect(sawText).toBeDefined();
|
|
264
|
+
const parents = parentMap();
|
|
265
|
+
const rowId = parents.get(parents.get(sawText[1])!)!;
|
|
266
|
+
|
|
267
|
+
dispatch({ kind: "event", nodeId: rowId, type: "click" });
|
|
268
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
269
|
+
|
|
270
|
+
expect(seen).toEqual([1]);
|
|
271
|
+
// menu removed after selection
|
|
272
|
+
expect(allOps().some((op: any) => op[0] === "removeChild")).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("ParamSelect writes a begin/set/end gesture with the index mapping", async () => {
|
|
276
|
+
paramGetResult = { value: 0, text: "Sine", name: "Shape", label: "" };
|
|
277
|
+
const { ParamSelect } = require("./index");
|
|
278
|
+
render(<ParamSelect paramId="shape" options={["SINE", "SAW", "SQR"]} />);
|
|
279
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
280
|
+
|
|
281
|
+
const trigger: any = opsNamed("setProps").find(
|
|
282
|
+
(op: any) => op[2]?.listeners?.includes("layout") && op[2]?.listeners?.includes("click"),
|
|
283
|
+
);
|
|
284
|
+
dispatch({
|
|
285
|
+
kind: "event",
|
|
286
|
+
nodeId: trigger[1],
|
|
287
|
+
type: "layout",
|
|
288
|
+
payload: { x: 0, y: 0, width: 160, height: 33 },
|
|
289
|
+
});
|
|
290
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
291
|
+
dispatch({ kind: "event", nodeId: trigger[1], type: "click" });
|
|
292
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
293
|
+
|
|
294
|
+
const sqrText: any = allOps().find((op: any) => op[0] === "setText" && op[2] === "SQR");
|
|
295
|
+
const parents = parentMap();
|
|
296
|
+
const rowId = parents.get(parents.get(sqrText[1])!)!;
|
|
297
|
+
dispatch({ kind: "event", nodeId: rowId, type: "click" });
|
|
298
|
+
|
|
299
|
+
const set = nativeCalls.find((c) => c.name === "param:set");
|
|
300
|
+
expect(set?.args).toEqual({ id: "shape", value: 1 });
|
|
301
|
+
expect(nativeCalls.some((c) => c.name === "param:begin")).toBe(true);
|
|
302
|
+
expect(nativeCalls.some((c) => c.name === "param:end")).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe("DAW feel (0.0.6)", () => {
|
|
307
|
+
test("Knob: double-click resets to defaultValue as a full gesture", () => {
|
|
308
|
+
const { Knob } = require("./index");
|
|
309
|
+
const calls: string[] = [];
|
|
310
|
+
render(
|
|
311
|
+
<Knob
|
|
312
|
+
value={0.9}
|
|
313
|
+
defaultValue={0.5}
|
|
314
|
+
onChange={(v: number) => calls.push(`set:${v}`)}
|
|
315
|
+
onBegin={() => calls.push("begin")}
|
|
316
|
+
onEnd={() => calls.push("end")}
|
|
317
|
+
/>,
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const id = nodeWithListener("dblclick");
|
|
321
|
+
dispatch({ kind: "event", nodeId: id, type: "dblclick" });
|
|
322
|
+
expect(calls).toEqual(["begin", "set:0.5", "end"]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("Knob: wheel nudges by dy * sensitivity, clamped", () => {
|
|
326
|
+
const { Knob } = require("./index");
|
|
327
|
+
const seen: number[] = [];
|
|
328
|
+
render(<Knob value={0.5} wheelSensitivity={0.4} onChange={(v: number) => seen.push(v)} />);
|
|
329
|
+
|
|
330
|
+
const id = nodeWithListener("wheel");
|
|
331
|
+
dispatch({ kind: "event", nodeId: id, type: "wheel", payload: { dy: 0.1 } });
|
|
332
|
+
expect(seen.at(-1)).toBeCloseTo(0.54);
|
|
333
|
+
|
|
334
|
+
dispatch({ kind: "event", nodeId: id, type: "wheel", payload: { dy: 100 } });
|
|
335
|
+
expect(seen.at(-1)).toBe(1);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("Knob: bipolar arc sweeps from centre in both directions", () => {
|
|
339
|
+
const { Knob } = require("./index");
|
|
340
|
+
render(<Knob bipolar value={0.75} onChange={() => {}} />);
|
|
341
|
+
let arc: any = opsNamed("setProps").find((op: any) => op[2]?.style?.arcValueEnd !== undefined);
|
|
342
|
+
expect(arc[2].style.arcValueStart).toBe(0);
|
|
343
|
+
expect(arc[2].style.arcValueEnd).toBeCloseTo(67.5);
|
|
344
|
+
|
|
345
|
+
unmount();
|
|
346
|
+
batches.length = 0;
|
|
347
|
+
render(<Knob bipolar value={0.25} onChange={() => {}} />);
|
|
348
|
+
arc = opsNamed("setProps").find((op: any) => op[2]?.style?.arcValueEnd !== undefined);
|
|
349
|
+
expect(arc[2].style.arcValueStart).toBeCloseTo(-67.5);
|
|
350
|
+
expect(arc[2].style.arcValueEnd).toBe(0);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("ParamKnob: host default drives the double-click reset", () => {
|
|
354
|
+
paramGetResult = { value: 0.9, text: "+5 dB", name: "Gain", label: "dB", defaultValue: 0.25 };
|
|
355
|
+
const { ParamKnob } = require("./index");
|
|
356
|
+
render(<ParamKnob paramId="gain" />);
|
|
357
|
+
|
|
358
|
+
const id = nodeWithListener("dblclick");
|
|
359
|
+
dispatch({ kind: "event", nodeId: id, type: "dblclick" });
|
|
360
|
+
|
|
361
|
+
const set = nativeCalls.find((c) => c.name === "param:set");
|
|
362
|
+
expect(set?.args).toEqual({ id: "gain", value: 0.25 });
|
|
363
|
+
expect(nativeCalls.some((c) => c.name === "param:begin")).toBe(true);
|
|
364
|
+
expect(nativeCalls.some((c) => c.name === "param:end")).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("Slider: double-click reset + wheel work in both orientations", () => {
|
|
368
|
+
const { Slider } = require("./index");
|
|
369
|
+
const seen: number[] = [];
|
|
370
|
+
render(<Slider vertical value={0.8} defaultValue={0.5} onChange={(v: number) => seen.push(v)} />);
|
|
371
|
+
|
|
372
|
+
dispatch({ kind: "event", nodeId: nodeWithListener("dblclick"), type: "dblclick" });
|
|
373
|
+
expect(seen.at(-1)).toBe(0.5);
|
|
374
|
+
|
|
375
|
+
dispatch({ kind: "event", nodeId: nodeWithListener("wheel"), type: "wheel", payload: { dy: -0.1 } });
|
|
376
|
+
expect(seen.at(-1)).toBeCloseTo(0.76);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe("Tooltip + Modal", () => {
|
|
381
|
+
test("Tooltip shows below its anchor after the hover delay, hides on leave", async () => {
|
|
382
|
+
const { Tooltip } = require("./index");
|
|
383
|
+
render(
|
|
384
|
+
<Tooltip label="Resets to 0 dB" delayMs={15} offset={6}>
|
|
385
|
+
<Slider value={0.5} onChange={() => {}} />
|
|
386
|
+
</Tooltip>,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
const anchor = nodeWithListener("mouseenter");
|
|
390
|
+
dispatch({
|
|
391
|
+
kind: "event",
|
|
392
|
+
nodeId: anchor,
|
|
393
|
+
type: "layout",
|
|
394
|
+
payload: { x: 10, y: 20, width: 100, height: 30 },
|
|
395
|
+
});
|
|
396
|
+
dispatch({ kind: "event", nodeId: anchor, type: "mouseenter" });
|
|
397
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
398
|
+
|
|
399
|
+
const tip: any = opsNamed("setProps").find(
|
|
400
|
+
(op: any) => op[2]?.style?.top === 20 + 30 + 6 && op[2]?.style?.left === 10,
|
|
401
|
+
);
|
|
402
|
+
expect(tip).toBeDefined();
|
|
403
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "Resets to 0 dB")).toBe(true);
|
|
404
|
+
|
|
405
|
+
dispatch({ kind: "event", nodeId: anchor, type: "mouseleave" });
|
|
406
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
407
|
+
expect(allOps().some((op: any) => op[0] === "removeChild")).toBe(true);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("Modal renders a centered panel; backdrop closes, panel doesn't", async () => {
|
|
411
|
+
const { Modal, Text: T } = require("./index");
|
|
412
|
+
const closed: number[] = [];
|
|
413
|
+
render(
|
|
414
|
+
<Modal open onClose={() => closed.push(1)} title="ABOUT" width={280}>
|
|
415
|
+
<T>VSReacT 0.0.6</T>
|
|
416
|
+
</Modal>,
|
|
417
|
+
);
|
|
418
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
419
|
+
|
|
420
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "ABOUT")).toBe(true);
|
|
421
|
+
const panel: any = opsNamed("setProps").find((op: any) => op[2]?.style?.width === 280);
|
|
422
|
+
expect(panel).toBeDefined();
|
|
423
|
+
|
|
424
|
+
// panel click swallows; backdrop click closes
|
|
425
|
+
dispatch({ kind: "event", nodeId: panel[1], type: "click" });
|
|
426
|
+
expect(closed.length).toBe(0);
|
|
427
|
+
|
|
428
|
+
const backdrop: any = opsNamed("setProps").find(
|
|
429
|
+
(op: any) => op[2]?.listeners?.includes("click") && op[2]?.style?.left === 0 && op[2]?.style?.right === 0,
|
|
430
|
+
);
|
|
431
|
+
expect(backdrop).toBeDefined();
|
|
432
|
+
dispatch({ kind: "event", nodeId: backdrop[1], type: "click" });
|
|
433
|
+
expect(closed).toEqual([1]);
|
|
434
|
+
});
|
|
435
|
+
});
|
package/src/controls.tsx
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// switches — drawn by the VSReacT painter and driven by drag gestures.
|
|
3
3
|
// Each has a Param* variant bound to an APVTS parameter.
|
|
4
4
|
|
|
5
|
-
import { useRef } from "react";
|
|
5
|
+
import { useEffect, useRef, useState } from "react";
|
|
6
6
|
import { View, Text } from "./primitives";
|
|
7
7
|
import { useParameter, useParameterList } from "./parameters";
|
|
8
8
|
import { useSpring } from "./animation";
|
|
9
|
+
import { useLayoutRect } from "./hooks";
|
|
10
|
+
import { useOverlay } from "./overlay";
|
|
9
11
|
|
|
10
12
|
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
|
11
13
|
|
|
@@ -23,6 +25,13 @@ export interface KnobProps {
|
|
|
23
25
|
label?: string;
|
|
24
26
|
size?: number;
|
|
25
27
|
disabled?: boolean;
|
|
28
|
+
/** Double-click resets to this (DAW convention). */
|
|
29
|
+
defaultValue?: number;
|
|
30
|
+
/** Value arc sweeps from 12 o'clock instead of the left stop — for
|
|
31
|
+
centre-based parameters like pan. */
|
|
32
|
+
bipolar?: boolean;
|
|
33
|
+
/** Value change per wheel notch fraction. 0 disables. Default 0.4. */
|
|
34
|
+
wheelSensitivity?: number;
|
|
26
35
|
trackColor?: string;
|
|
27
36
|
valueColor?: string;
|
|
28
37
|
onChange: (value: number) => void;
|
|
@@ -36,6 +45,9 @@ export function Knob({
|
|
|
36
45
|
label,
|
|
37
46
|
size = 64,
|
|
38
47
|
disabled,
|
|
48
|
+
defaultValue,
|
|
49
|
+
bipolar,
|
|
50
|
+
wheelSensitivity = 0.4,
|
|
39
51
|
trackColor = "#2A2F27",
|
|
40
52
|
valueColor = "#C6F135",
|
|
41
53
|
onChange,
|
|
@@ -43,6 +55,15 @@ export function Knob({
|
|
|
43
55
|
onEnd,
|
|
44
56
|
}: KnobProps) {
|
|
45
57
|
const startValue = useRef(0);
|
|
58
|
+
const clamped = clamp01(value);
|
|
59
|
+
const angle = ARC_START + (ARC_END - ARC_START) * clamped;
|
|
60
|
+
const center = (ARC_START + ARC_END) / 2;
|
|
61
|
+
|
|
62
|
+
const nudge = (target: number) => {
|
|
63
|
+
onBegin?.();
|
|
64
|
+
onChange(clamp01(target));
|
|
65
|
+
onEnd?.();
|
|
66
|
+
};
|
|
46
67
|
|
|
47
68
|
return (
|
|
48
69
|
<View className="items-center gap-2">
|
|
@@ -55,19 +76,28 @@ export function Knob({
|
|
|
55
76
|
arcColor: valueColor,
|
|
56
77
|
arcStart: ARC_START,
|
|
57
78
|
arcEnd: ARC_END,
|
|
58
|
-
|
|
79
|
+
arcValueStart: bipolar ? Math.min(center, angle) : ARC_START,
|
|
80
|
+
arcValueEnd: bipolar ? Math.max(center, angle) : angle,
|
|
59
81
|
arcThickness: Math.max(3, size * 0.08),
|
|
60
82
|
}}
|
|
61
83
|
onDragStart={
|
|
62
84
|
disabled
|
|
63
85
|
? undefined
|
|
64
86
|
: () => {
|
|
65
|
-
startValue.current =
|
|
87
|
+
startValue.current = clamped;
|
|
66
88
|
onBegin?.();
|
|
67
89
|
}
|
|
68
90
|
}
|
|
69
91
|
onDrag={disabled ? undefined : (e) => onChange(dragToValue(startValue.current, e.dy))}
|
|
70
92
|
onDragEnd={disabled ? undefined : () => onEnd?.()}
|
|
93
|
+
onDoubleClick={
|
|
94
|
+
disabled || defaultValue === undefined ? undefined : () => nudge(defaultValue)
|
|
95
|
+
}
|
|
96
|
+
onWheel={
|
|
97
|
+
disabled || wheelSensitivity === 0
|
|
98
|
+
? undefined
|
|
99
|
+
: (e) => nudge(clamped + e.dy * wheelSensitivity)
|
|
100
|
+
}
|
|
71
101
|
>
|
|
72
102
|
{text !== undefined ? (
|
|
73
103
|
<Text
|
|
@@ -89,12 +119,15 @@ export interface ParamKnobProps {
|
|
|
89
119
|
paramId: string;
|
|
90
120
|
label?: string;
|
|
91
121
|
size?: number;
|
|
122
|
+
bipolar?: boolean;
|
|
123
|
+
wheelSensitivity?: number;
|
|
92
124
|
trackColor?: string;
|
|
93
125
|
valueColor?: string;
|
|
94
126
|
}
|
|
95
127
|
|
|
96
|
-
/** A Knob bound to an APVTS parameter
|
|
97
|
-
|
|
128
|
+
/** A Knob bound to an APVTS parameter — double-click resets to the
|
|
129
|
+
host's default, the wheel nudges, gestures stay automation-safe. */
|
|
130
|
+
export function ParamKnob({ paramId, label, ...rest }: ParamKnobProps) {
|
|
98
131
|
const param = useParameter(paramId);
|
|
99
132
|
|
|
100
133
|
return (
|
|
@@ -102,12 +135,11 @@ export function ParamKnob({ paramId, label, size, trackColor, valueColor }: Para
|
|
|
102
135
|
value={param.value}
|
|
103
136
|
text={param.text}
|
|
104
137
|
label={label ?? param.name.toUpperCase()}
|
|
105
|
-
|
|
106
|
-
trackColor={trackColor}
|
|
107
|
-
valueColor={valueColor}
|
|
138
|
+
defaultValue={param.defaultValue}
|
|
108
139
|
onChange={param.set}
|
|
109
140
|
onBegin={param.begin}
|
|
110
141
|
onEnd={param.end}
|
|
142
|
+
{...rest}
|
|
111
143
|
/>
|
|
112
144
|
);
|
|
113
145
|
}
|
|
@@ -122,6 +154,10 @@ export interface SliderProps {
|
|
|
122
154
|
vertical?: boolean;
|
|
123
155
|
label?: string;
|
|
124
156
|
disabled?: boolean;
|
|
157
|
+
/** Double-click resets to this (DAW convention). */
|
|
158
|
+
defaultValue?: number;
|
|
159
|
+
/** Value change per wheel notch fraction. 0 disables. Default 0.4. */
|
|
160
|
+
wheelSensitivity?: number;
|
|
125
161
|
trackColor?: string;
|
|
126
162
|
valueColor?: string;
|
|
127
163
|
onChange: (value: number) => void;
|
|
@@ -136,6 +172,8 @@ export function Slider({
|
|
|
136
172
|
vertical,
|
|
137
173
|
label,
|
|
138
174
|
disabled,
|
|
175
|
+
defaultValue,
|
|
176
|
+
wheelSensitivity = 0.4,
|
|
139
177
|
trackColor = "#2A2F27",
|
|
140
178
|
valueColor = "#C6F135",
|
|
141
179
|
onChange,
|
|
@@ -153,6 +191,18 @@ export function Slider({
|
|
|
153
191
|
};
|
|
154
192
|
const onDragEnd = disabled ? undefined : () => onEnd?.();
|
|
155
193
|
|
|
194
|
+
const nudge = (target: number) => {
|
|
195
|
+
onBegin?.();
|
|
196
|
+
onChange(clamp01(target));
|
|
197
|
+
onEnd?.();
|
|
198
|
+
};
|
|
199
|
+
const onDoubleClick =
|
|
200
|
+
disabled || defaultValue === undefined ? undefined : () => nudge(defaultValue);
|
|
201
|
+
const onWheel =
|
|
202
|
+
disabled || wheelSensitivity === 0
|
|
203
|
+
? undefined
|
|
204
|
+
: (e: { dy: number }) => nudge(clamped + e.dy * wheelSensitivity);
|
|
205
|
+
|
|
156
206
|
if (vertical) {
|
|
157
207
|
return (
|
|
158
208
|
<View className="items-center gap-2">
|
|
@@ -162,6 +212,8 @@ export function Slider({
|
|
|
162
212
|
onDragStart={onDragStart}
|
|
163
213
|
onDrag={disabled ? undefined : (e) => onChange(clamp01(startValue.current - e.dy / height))}
|
|
164
214
|
onDragEnd={onDragEnd}
|
|
215
|
+
onDoubleClick={onDoubleClick}
|
|
216
|
+
onWheel={onWheel}
|
|
165
217
|
>
|
|
166
218
|
<View
|
|
167
219
|
className="absolute w-[4] rounded-full left-[7] top-0 bottom-0"
|
|
@@ -191,6 +243,8 @@ export function Slider({
|
|
|
191
243
|
onDragStart={onDragStart}
|
|
192
244
|
onDrag={disabled ? undefined : (e) => onChange(clamp01(startValue.current + e.dx / width))}
|
|
193
245
|
onDragEnd={onDragEnd}
|
|
246
|
+
onDoubleClick={onDoubleClick}
|
|
247
|
+
onWheel={onWheel}
|
|
194
248
|
>
|
|
195
249
|
<View className="h-[4] rounded-full" style={{ backgroundColor: trackColor }} />
|
|
196
250
|
<View
|
|
@@ -215,22 +269,23 @@ export interface ParamSliderProps {
|
|
|
215
269
|
width?: number;
|
|
216
270
|
height?: number;
|
|
217
271
|
vertical?: boolean;
|
|
272
|
+
wheelSensitivity?: number;
|
|
218
273
|
}
|
|
219
274
|
|
|
220
|
-
/** A Slider bound to an APVTS parameter
|
|
221
|
-
|
|
275
|
+
/** A Slider bound to an APVTS parameter — double-click resets to the
|
|
276
|
+
host's default, the wheel nudges, gestures stay automation-safe. */
|
|
277
|
+
export function ParamSlider({ paramId, label, ...rest }: ParamSliderProps) {
|
|
222
278
|
const param = useParameter(paramId);
|
|
223
279
|
|
|
224
280
|
return (
|
|
225
281
|
<Slider
|
|
226
282
|
value={param.value}
|
|
227
283
|
label={label ?? param.name.toUpperCase()}
|
|
228
|
-
|
|
229
|
-
height={height}
|
|
230
|
-
vertical={vertical}
|
|
284
|
+
defaultValue={param.defaultValue}
|
|
231
285
|
onChange={param.set}
|
|
232
286
|
onBegin={param.begin}
|
|
233
287
|
onEnd={param.end}
|
|
288
|
+
{...rest}
|
|
234
289
|
/>
|
|
235
290
|
);
|
|
236
291
|
}
|
|
@@ -564,3 +619,151 @@ export function GenericEditor({ columns = 4, size = 72, trackColor, valueColor }
|
|
|
564
619
|
</View>
|
|
565
620
|
);
|
|
566
621
|
}
|
|
622
|
+
|
|
623
|
+
// ── Select ─────────────────────────────────────────────────────────────
|
|
624
|
+
|
|
625
|
+
export interface SelectProps {
|
|
626
|
+
options: string[];
|
|
627
|
+
index: number;
|
|
628
|
+
/** Trigger width; the menu matches it. Default 160. */
|
|
629
|
+
width?: number;
|
|
630
|
+
label?: string;
|
|
631
|
+
disabled?: boolean;
|
|
632
|
+
trackColor?: string;
|
|
633
|
+
menuColor?: string;
|
|
634
|
+
activeColor?: string;
|
|
635
|
+
textColor?: string;
|
|
636
|
+
activeTextColor?: string;
|
|
637
|
+
/** Menu scrolls beyond this height. Default 190. */
|
|
638
|
+
maxMenuHeight?: number;
|
|
639
|
+
onChange: (index: number) => void;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const SELECT_ROW_HEIGHT = 30;
|
|
643
|
+
|
|
644
|
+
/** A dropdown: the menu renders in the overlay layer, positioned under
|
|
645
|
+
the trigger via onLayout, with a click-away backdrop and a scrolling
|
|
646
|
+
option list. */
|
|
647
|
+
export function Select({
|
|
648
|
+
options,
|
|
649
|
+
index,
|
|
650
|
+
width = 160,
|
|
651
|
+
label,
|
|
652
|
+
disabled,
|
|
653
|
+
trackColor = "#2A2F27",
|
|
654
|
+
menuColor = "#20241F",
|
|
655
|
+
activeColor = "#C6F135",
|
|
656
|
+
textColor = "#d4d4d8",
|
|
657
|
+
activeTextColor = "#09090b",
|
|
658
|
+
maxMenuHeight = 190,
|
|
659
|
+
onChange,
|
|
660
|
+
}: SelectProps) {
|
|
661
|
+
const [open, setOpen] = useState(false);
|
|
662
|
+
const [rect, onLayout] = useLayoutRect();
|
|
663
|
+
const overlay = useOverlay();
|
|
664
|
+
const current = Math.min(options.length - 1, Math.max(0, index));
|
|
665
|
+
|
|
666
|
+
useEffect(() => {
|
|
667
|
+
if (!open || rect === null) {
|
|
668
|
+
overlay.hide();
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const menuHeight = Math.min(options.length * SELECT_ROW_HEIGHT + 8, maxMenuHeight);
|
|
673
|
+
|
|
674
|
+
overlay.show(
|
|
675
|
+
<View className="absolute inset-0" onClick={() => setOpen(false)}>
|
|
676
|
+
<View
|
|
677
|
+
className="absolute rounded-lg border shadow-lg overflow-hidden"
|
|
678
|
+
style={{
|
|
679
|
+
left: rect.x,
|
|
680
|
+
top: rect.y + rect.height + 4,
|
|
681
|
+
width: rect.width,
|
|
682
|
+
backgroundColor: menuColor,
|
|
683
|
+
borderColor: "#00000066",
|
|
684
|
+
}}
|
|
685
|
+
>
|
|
686
|
+
<View className="overflow-y-scroll p-[4] gap-[2]" style={{ height: menuHeight }}>
|
|
687
|
+
{options.map((option, i) => (
|
|
688
|
+
<View
|
|
689
|
+
key={`${option}-${i}`}
|
|
690
|
+
className="px-3 py-[6] rounded cursor-pointer hover:bg-white/10"
|
|
691
|
+
style={{ backgroundColor: i === current ? activeColor : "#00000000" }}
|
|
692
|
+
onClick={() => {
|
|
693
|
+
onChange(i);
|
|
694
|
+
setOpen(false);
|
|
695
|
+
}}
|
|
696
|
+
>
|
|
697
|
+
<Text
|
|
698
|
+
className="text-[12] font-medium"
|
|
699
|
+
style={{ color: i === current ? activeTextColor : textColor }}
|
|
700
|
+
>
|
|
701
|
+
{option}
|
|
702
|
+
</Text>
|
|
703
|
+
</View>
|
|
704
|
+
))}
|
|
705
|
+
</View>
|
|
706
|
+
</View>
|
|
707
|
+
</View>,
|
|
708
|
+
);
|
|
709
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
710
|
+
}, [open, rect, options, current, menuColor, activeColor, textColor, activeTextColor, maxMenuHeight]);
|
|
711
|
+
|
|
712
|
+
return (
|
|
713
|
+
<View className="items-center gap-2">
|
|
714
|
+
<View
|
|
715
|
+
className={`flex-row items-center justify-between rounded-lg border px-3 py-[8] ${
|
|
716
|
+
disabled ? "opacity-40" : "cursor-pointer"
|
|
717
|
+
}`}
|
|
718
|
+
style={{ width, backgroundColor: trackColor, borderColor: "#00000055" }}
|
|
719
|
+
onLayout={onLayout}
|
|
720
|
+
onClick={disabled ? undefined : () => setOpen((v) => !v)}
|
|
721
|
+
>
|
|
722
|
+
<Text className="text-[12] font-medium" style={{ color: textColor }}>
|
|
723
|
+
{options[current] ?? ""}
|
|
724
|
+
</Text>
|
|
725
|
+
<Text className="text-[9]" style={{ color: textColor, opacity: 0.7 }}>
|
|
726
|
+
{open ? "▲" : "▼"}
|
|
727
|
+
</Text>
|
|
728
|
+
</View>
|
|
729
|
+
{label !== undefined ? (
|
|
730
|
+
<Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
|
|
731
|
+
) : null}
|
|
732
|
+
</View>
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export interface ParamSelectProps {
|
|
737
|
+
paramId: string;
|
|
738
|
+
options: string[];
|
|
739
|
+
width?: number;
|
|
740
|
+
label?: string;
|
|
741
|
+
trackColor?: string;
|
|
742
|
+
menuColor?: string;
|
|
743
|
+
activeColor?: string;
|
|
744
|
+
textColor?: string;
|
|
745
|
+
activeTextColor?: string;
|
|
746
|
+
maxMenuHeight?: number;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** A Select bound to a choice-style APVTS parameter (same value↔index
|
|
750
|
+
mapping as ParamSegmented). */
|
|
751
|
+
export function ParamSelect({ paramId, options, label, ...rest }: ParamSelectProps) {
|
|
752
|
+
const param = useParameter(paramId);
|
|
753
|
+
const count = Math.max(1, options.length);
|
|
754
|
+
const index = Math.round(param.value * (count - 1));
|
|
755
|
+
|
|
756
|
+
return (
|
|
757
|
+
<Select
|
|
758
|
+
options={options}
|
|
759
|
+
index={index}
|
|
760
|
+
label={label ?? param.name.toUpperCase()}
|
|
761
|
+
onChange={(i) => {
|
|
762
|
+
param.begin();
|
|
763
|
+
param.set(count <= 1 ? 0 : i / (count - 1));
|
|
764
|
+
param.end();
|
|
765
|
+
}}
|
|
766
|
+
{...rest}
|
|
767
|
+
/>
|
|
768
|
+
);
|
|
769
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
import { native } from "./native";
|
|
5
|
+
import type { LayoutRect } from "./primitives";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Captures a node's root-space rect from its onLayout prop:
|
|
9
|
+
*
|
|
10
|
+
* const [rect, onLayout] = useLayoutRect();
|
|
11
|
+
* <View onLayout={onLayout} /> // rect updates whenever layout moves it
|
|
12
|
+
*/
|
|
13
|
+
export function useLayoutRect(): [LayoutRect | null, (rect: LayoutRect) => void] {
|
|
14
|
+
const [rect, setRect] = useState<LayoutRect | null>(null);
|
|
15
|
+
return [rect, setRect];
|
|
16
|
+
}
|
|
5
17
|
|
|
6
18
|
/**
|
|
7
19
|
* Subscribes to a C++ event (RootView::sendNativeEvent) for the lifetime
|
package/src/hostConfig.ts
CHANGED
|
@@ -24,6 +24,8 @@ const hostTypes: Record<string, string> = {
|
|
|
24
24
|
|
|
25
25
|
const eventPropNames: Record<string, string> = {
|
|
26
26
|
onClick: "click",
|
|
27
|
+
onDoubleClick: "dblclick",
|
|
28
|
+
onWheel: "wheel",
|
|
27
29
|
onMouseEnter: "mouseenter",
|
|
28
30
|
onMouseLeave: "mouseleave",
|
|
29
31
|
onMouseDown: "mousedown",
|
|
@@ -31,6 +33,7 @@ const eventPropNames: Record<string, string> = {
|
|
|
31
33
|
onDragStart: "dragstart",
|
|
32
34
|
onDrag: "drag",
|
|
33
35
|
onDragEnd: "dragend",
|
|
36
|
+
onLayout: "layout",
|
|
34
37
|
onChange: "change",
|
|
35
38
|
onSubmit: "submit",
|
|
36
39
|
onFocus: "focus",
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,10 @@ export type {
|
|
|
13
13
|
} from "./primitives";
|
|
14
14
|
export { render, unmount } from "./render";
|
|
15
15
|
export { native } from "./native";
|
|
16
|
-
export { useNativeEvent, useDebounced } from "./hooks";
|
|
16
|
+
export { useNativeEvent, useDebounced, useLayoutRect } from "./hooks";
|
|
17
|
+
export { useOverlay, OverlayLayer } from "./overlay";
|
|
18
|
+
export { Tooltip, Modal } from "./popover";
|
|
19
|
+
export type { TooltipProps, ModalProps } from "./popover";
|
|
17
20
|
export { configureTheme, tw } from "./tw";
|
|
18
21
|
export type { Style, ResolvedClasses } from "./tw";
|
|
19
22
|
export { cx } from "./cx";
|
|
@@ -28,12 +31,14 @@ export {
|
|
|
28
31
|
Toggle,
|
|
29
32
|
XYPad,
|
|
30
33
|
Segmented,
|
|
34
|
+
Select,
|
|
31
35
|
GenericEditor,
|
|
32
36
|
ParamKnob,
|
|
33
37
|
ParamSlider,
|
|
34
38
|
ParamToggle,
|
|
35
39
|
ParamXYPad,
|
|
36
40
|
ParamSegmented,
|
|
41
|
+
ParamSelect,
|
|
37
42
|
dragToValue,
|
|
38
43
|
} from "./controls";
|
|
39
44
|
export type {
|
|
@@ -42,13 +47,15 @@ export type {
|
|
|
42
47
|
ToggleProps,
|
|
43
48
|
XYPadProps,
|
|
44
49
|
SegmentedProps,
|
|
50
|
+
SelectProps,
|
|
45
51
|
GenericEditorProps,
|
|
46
52
|
ParamKnobProps,
|
|
47
53
|
ParamSliderProps,
|
|
48
54
|
ParamToggleProps,
|
|
49
55
|
ParamXYPadProps,
|
|
50
56
|
ParamSegmentedProps,
|
|
57
|
+
ParamSelectProps,
|
|
51
58
|
} from "./controls";
|
|
52
59
|
export { Meter, usePeakHold, peakHoldStep } from "./meter";
|
|
53
60
|
export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
|
|
54
|
-
export type { DragEventPayload } from "./primitives";
|
|
61
|
+
export type { DragEventPayload, LayoutRect, WheelEventPayload } from "./primitives";
|