Skip to content

Commit ae7c154

Browse files
authored
Knob: manual input (#26)
1 parent 83538c3 commit ae7c154

File tree

11 files changed

+320
-54
lines changed

11 files changed

+320
-54
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "adsr",
3-
"version": "0.1.1",
3+
"version": "0.2.0",
44
"scripts": {
55
"dev": "next dev",
66
"build": "next build",

src/components/ui/Knob.tsx

Lines changed: 174 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import {keyCodes} from '@/constants/key-codes';
2-
import {clamp01, mapFrom01Linear, mapTo01Linear} from '@/utils/math';
2+
import {isNumberKey} from '@/utils/keyboard';
3+
import {clamp, clamp01, mapFrom01Linear, mapTo01Linear} from '@/utils/math';
34
import {useDrag} from '@use-gesture/react';
45
import clsx from 'clsx';
5-
import {useId} from 'react';
6+
import {useEffect, useId, useRef, useState} from 'react';
67

78
export type KnobProps = {
89
isLarge?: boolean;
@@ -12,8 +13,32 @@ export type KnobProps = {
1213
min: number;
1314
max: number;
1415
onChange: (newValue: number) => void;
16+
/**
17+
* Used to display the value in the knob.
18+
* Note, that rounding must be applied here either, so raw slider values with lots of decimals will be handled properly.
19+
* Example: 1250.11011 Hz can be displayed as "1.25 kHz".
20+
*/
1521
displayValueFn: (value: number) => string;
22+
/**
23+
* Used to convert the value from the raw manual input to the knob's value.
24+
* Note, that rounding must be applied here either, so raw slider values with lots of decimals will be handled properly.
25+
* Example: 0.500001 value should be converted to 50 (%).
26+
*/
27+
toManualInputFn: (x: number) => number;
28+
/**
29+
* Opposite of `toManualInputFn`.
30+
* Example: user enters 50 (%), which is converted to 0.5.
31+
*/
32+
fromManualInputFn: (x: number) => number;
33+
/**
34+
* Used for mapping the value to the knob position (number from 0 to 1).
35+
* This is the place for making the interpolation, if non-linear one is required.
36+
* Example: logarithmic scale of frequency input, when knob center position 0.5 corresponds to ~ 1 kHz (instead of 10.1 kHz which is the "linear" center of frequency range).
37+
*/
1638
mapTo01?: (x: number, min: number, max: number) => number;
39+
/**
40+
* Opposite of `mapTo01`.
41+
*/
1742
mapFrom01?: (x: number, min: number, max: number) => number;
1843
};
1944

@@ -26,88 +51,194 @@ export function Knob({
2651
max,
2752
onChange,
2853
displayValueFn,
54+
toManualInputFn = (x) => x,
55+
fromManualInputFn = (x) => x,
2956
mapTo01 = mapTo01Linear,
3057
mapFrom01 = mapFrom01Linear,
3158
}: KnobProps) {
3259
const id = useId();
3360

61+
const knobContainerRef = useRef<HTMLDivElement>(null);
62+
63+
const [hasManualInputInitialValue, setHasManualInputInitialValue] =
64+
useState(true);
65+
const [isManualInputActive, setIsManualInputActive] = useState(false);
66+
const manualInputInitialValue = hasManualInputInitialValue
67+
? toManualInputFn(value)
68+
: undefined;
69+
70+
const openManualInput = (withDefaultValue: boolean) => {
71+
setHasManualInputInitialValue(withDefaultValue);
72+
setIsManualInputActive(true);
73+
};
74+
75+
const closeManualInput = () => {
76+
setIsManualInputActive(false);
77+
knobContainerRef.current?.focus(); // Re-focus back on the knob container
78+
};
79+
3480
const value01 = mapTo01(value, min, max);
3581
const valueText = displayValueFn(value);
3682

3783
const angleMin = -145; // The minumum knob position angle, when x = 0
3884
const angleMax = 145; // The maximum knob position angle, when x = 1
3985
const angle = mapFrom01Linear(value01, angleMin, angleMax);
4086

41-
const changeValueTo = (newValue01: number): void => {
42-
onChange(mapFrom01(newValue01, min, max));
87+
const changeValueTo = (newValue: number): void => {
88+
onChange(clamp(newValue, min, max));
89+
};
90+
91+
const changeValue01To = (newValue01: number): void => {
92+
changeValueTo(mapFrom01(newValue01, min, max));
4393
};
4494

45-
const changeValueBy = (diff01: number): void => {
46-
const newValue01 = clamp01(value01 + diff01);
47-
changeValueTo(newValue01);
95+
const changeValue01By = (diff01: number): void => {
96+
changeValue01To(clamp01(value01 + diff01));
4897
};
4998

50-
const onKeyDown: React.KeyboardEventHandler<HTMLDivElement> = ({code}) => {
99+
const onKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (event) => {
100+
const {code, key} = event;
101+
51102
if (code === keyCodes.arrowLeft || code === keyCodes.arrowDown) {
52-
changeValueBy(-0.01);
103+
changeValue01By(-0.01);
53104
return;
54105
}
55106

56107
if (code === keyCodes.arrowRight || code === keyCodes.arrowUp) {
57-
changeValueBy(0.01);
108+
changeValue01By(0.01);
58109
return;
59110
}
60111

61112
if (code === keyCodes.backspace || code === keyCodes.delete) {
62113
const defaultValue01 = mapTo01(defaultValue, min, max);
63-
changeValueTo(defaultValue01);
114+
changeValue01To(defaultValue01);
115+
return;
116+
}
117+
118+
if (isNumberKey(key)) {
119+
openManualInput(false);
64120
}
65121
};
66122

67123
const bindDrag = useDrag(({delta}) => {
68124
const diff01 = delta[1] * -0.006; // Multiplying by negative sensitivity. Vertical axis (Y) direction of the screen is inverted.
69-
changeValueBy(diff01);
125+
changeValue01By(diff01);
70126
});
71127

72128
return (
73-
<div
74-
className={clsx(
75-
'flex select-none flex-col items-center text-xs outline-none focus:outline-dashed focus:outline-1 focus:outline-gray-4',
76-
isLarge ? 'w-20' : 'w-16',
77-
)}
78-
tabIndex={-1} // Making element focusable by mouse / touch (not Tab). Details: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
79-
onKeyDown={onKeyDown}
80-
onPointerDown={(event) => {
81-
// Touch devices have a delay before focusing so it won't focus if touch immediately moves away from target (sliding). We want thumb to focus regardless.
82-
// See, for reference, Radix UI Slider does the same: https://github.com/radix-ui/primitives/blob/eca6babd188df465f64f23f3584738b85dba610e/packages/react/slider/src/Slider.tsx#L442-L445
83-
event.currentTarget.focus();
84-
}}
85-
>
86-
<label htmlFor={id}>{title}</label>
129+
<div className='relative text-xs'>
87130
<div
88-
id={id}
131+
ref={knobContainerRef}
89132
className={clsx(
90-
'relative touch-none', // It's recommended to disable "touch-action" for use-gesture: https://use-gesture.netlify.app/docs/extras/#touch-action
91-
isLarge ? 'h-16 w-16' : 'h-12 w-12',
133+
'flex select-none flex-col items-center outline-none focus:outline-dashed focus:outline-1 focus:outline-gray-4',
134+
isLarge ? 'w-20' : 'w-16',
92135
)}
93-
role='slider'
94-
aria-valuenow={value}
95-
aria-valuemin={min}
96-
aria-valuemax={max}
97-
aria-valuetext={valueText}
98-
aria-orientation='vertical'
99-
{...bindDrag()}
136+
tabIndex={-1} // Making element focusable by mouse / touch (not Tab). Details: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
137+
onKeyDown={onKeyDown}
138+
onPointerDown={(event) => {
139+
// Touch devices have a delay before focusing so it won't focus if touch immediately moves away from target (sliding). We want thumb to focus regardless.
140+
// See, for reference, Radix UI Slider does the same: https://github.com/radix-ui/primitives/blob/eca6babd188df465f64f23f3584738b85dba610e/packages/react/slider/src/Slider.tsx#L442-L445
141+
event.currentTarget.focus();
142+
}}
100143
>
101-
<div className='absolute h-full w-full rounded-full bg-gray-3'>
102-
<div
103-
className='absolute h-full w-full'
104-
style={{rotate: `${angle}deg`}}
105-
>
106-
<div className='absolute left-1/2 top-0 h-1/2 w-[2px] -translate-x-1/2 rounded-sm bg-gray-7' />
144+
<label htmlFor={id}>{title}</label>
145+
<div
146+
id={id}
147+
className={clsx(
148+
'relative touch-none', // It's recommended to disable "touch-action" for use-gesture: https://use-gesture.netlify.app/docs/extras/#touch-action
149+
isLarge ? 'h-16 w-16' : 'h-12 w-12',
150+
)}
151+
role='slider'
152+
aria-valuenow={value}
153+
aria-valuemin={min}
154+
aria-valuemax={max}
155+
aria-valuetext={valueText}
156+
aria-orientation='vertical'
157+
{...bindDrag()}
158+
>
159+
<div className='absolute h-full w-full rounded-full bg-gray-3'>
160+
<div
161+
className='absolute h-full w-full'
162+
style={{rotate: `${angle}deg`}}
163+
>
164+
<div className='absolute left-1/2 top-0 h-1/2 w-[2px] -translate-x-1/2 rounded-sm bg-gray-7' />
165+
</div>
107166
</div>
108167
</div>
168+
<label
169+
htmlFor={id}
170+
onClick={() => {
171+
openManualInput(true);
172+
}}
173+
>
174+
{valueText}
175+
</label>
109176
</div>
110-
<label htmlFor={id}>{valueText}</label>
177+
{isManualInputActive && (
178+
<ManualInput
179+
initialValue={manualInputInitialValue}
180+
onCancel={closeManualInput}
181+
onSubmit={(newValue) => {
182+
closeManualInput();
183+
changeValueTo(fromManualInputFn(newValue));
184+
}}
185+
/>
186+
)}
111187
</div>
112188
);
113189
}
190+
191+
type ManualInputProps = {
192+
initialValue?: number;
193+
onCancel: () => void;
194+
onSubmit: (newValue: number) => void;
195+
};
196+
197+
function ManualInput({initialValue, onCancel, onSubmit}: ManualInputProps) {
198+
const inputRef = useRef<HTMLInputElement>(null);
199+
useEffect(() => {
200+
inputRef.current?.focus(); // Focus on the input when it's mounted
201+
}, []);
202+
203+
const isCancelledRef = useRef<boolean>(false);
204+
205+
const submit = () => {
206+
if (isCancelledRef.current) return;
207+
onSubmit(Number(inputRef.current?.value));
208+
};
209+
210+
return (
211+
<form
212+
noValidate
213+
className='absolute inset-x-0 bottom-0 w-full'
214+
onSubmit={(event) => {
215+
event.preventDefault(); // Prevent standard form submission behavior
216+
submit();
217+
}}
218+
>
219+
<input
220+
ref={inputRef}
221+
defaultValue={initialValue}
222+
type='number'
223+
className='w-full border border-gray-0 bg-gray-7 text-center text-gray-0 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
224+
onBlur={submit}
225+
onKeyDown={(event) => {
226+
// Prevent standard input behaviour when it's being changed on arrow up/down press
227+
if (
228+
event.code === keyCodes.arrowDown ||
229+
event.code === keyCodes.arrowUp
230+
) {
231+
event.preventDefault();
232+
return;
233+
}
234+
235+
// Cancel on escape
236+
if (event.code === keyCodes.escape) {
237+
isCancelledRef.current = true;
238+
onCancel();
239+
}
240+
}}
241+
/>
242+
</form>
243+
);
244+
}

src/components/ui/KnobAdr.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import {Knob, type KnobProps} from './Knob';
2-
import {NormalisableRange} from '@/utils/math';
2+
import {NormalisableRange, round} from '@/utils/math';
33
import {useConst} from '@/components/hooks/useConst';
44

55
export type KnobAdrProps = Omit<
66
KnobProps,
7-
'min' | 'max' | 'displayValueFn' | 'mapTo01' | 'mapFrom01'
7+
| 'min'
8+
| 'max'
9+
| 'displayValueFn'
10+
| 'toManualInputFn'
11+
| 'fromManualInputFn'
12+
| 'mapTo01'
13+
| 'mapFrom01'
814
>;
915

1016
export function KnobAdr(props: KnobAdrProps) {
@@ -20,6 +26,8 @@ export function KnobAdr(props: KnobAdrProps) {
2026
min={min}
2127
max={max}
2228
displayValueFn={displayValueFn}
29+
toManualInputFn={toManualInputFn}
30+
fromManualInputFn={fromManualInputFn}
2331
mapTo01={mapTo01}
2432
mapFrom01={mapFrom01}
2533
{...props}
@@ -48,3 +56,27 @@ const displayValueFn = (s: number) => {
4856

4957
return `${s.toFixed(1)} s`;
5058
};
59+
60+
const toManualInputFn = (s: number): number => {
61+
const ms = s * 1000;
62+
63+
if (ms < 10) {
64+
return round(ms, 2);
65+
}
66+
67+
if (ms < 100) {
68+
return round(ms, 1);
69+
}
70+
71+
if (ms < 1000) {
72+
return round(ms);
73+
}
74+
75+
if (ms < 10000) {
76+
return round(ms, -1);
77+
}
78+
79+
return round(ms, -2);
80+
};
81+
82+
const fromManualInputFn = (ms: number): number => ms / 1000;

0 commit comments

Comments
 (0)