Skip to content

Commit

Permalink
Knob: manual input (#26)
Browse files Browse the repository at this point in the history
  • Loading branch information
satelllte authored Aug 1, 2023
1 parent 83538c3 commit ae7c154
Show file tree
Hide file tree
Showing 11 changed files with 320 additions and 54 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "adsr",
"version": "0.1.1",
"version": "0.2.0",
"scripts": {
"dev": "next dev",
"build": "next build",
Expand Down
217 changes: 174 additions & 43 deletions src/components/ui/Knob.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {keyCodes} from '@/constants/key-codes';
import {clamp01, mapFrom01Linear, mapTo01Linear} from '@/utils/math';
import {isNumberKey} from '@/utils/keyboard';
import {clamp, clamp01, mapFrom01Linear, mapTo01Linear} from '@/utils/math';
import {useDrag} from '@use-gesture/react';
import clsx from 'clsx';
import {useId} from 'react';
import {useEffect, useId, useRef, useState} from 'react';

export type KnobProps = {
isLarge?: boolean;
Expand All @@ -12,8 +13,32 @@ export type KnobProps = {
min: number;
max: number;
onChange: (newValue: number) => void;
/**
* Used to display the value in the knob.
* Note, that rounding must be applied here either, so raw slider values with lots of decimals will be handled properly.
* Example: 1250.11011 Hz can be displayed as "1.25 kHz".
*/
displayValueFn: (value: number) => string;
/**
* Used to convert the value from the raw manual input to the knob's value.
* Note, that rounding must be applied here either, so raw slider values with lots of decimals will be handled properly.
* Example: 0.500001 value should be converted to 50 (%).
*/
toManualInputFn: (x: number) => number;
/**
* Opposite of `toManualInputFn`.
* Example: user enters 50 (%), which is converted to 0.5.
*/
fromManualInputFn: (x: number) => number;
/**
* Used for mapping the value to the knob position (number from 0 to 1).
* This is the place for making the interpolation, if non-linear one is required.
* 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).
*/
mapTo01?: (x: number, min: number, max: number) => number;
/**
* Opposite of `mapTo01`.
*/
mapFrom01?: (x: number, min: number, max: number) => number;
};

Expand All @@ -26,88 +51,194 @@ export function Knob({
max,
onChange,
displayValueFn,
toManualInputFn = (x) => x,
fromManualInputFn = (x) => x,
mapTo01 = mapTo01Linear,
mapFrom01 = mapFrom01Linear,
}: KnobProps) {
const id = useId();

const knobContainerRef = useRef<HTMLDivElement>(null);

const [hasManualInputInitialValue, setHasManualInputInitialValue] =
useState(true);
const [isManualInputActive, setIsManualInputActive] = useState(false);
const manualInputInitialValue = hasManualInputInitialValue
? toManualInputFn(value)
: undefined;

const openManualInput = (withDefaultValue: boolean) => {
setHasManualInputInitialValue(withDefaultValue);
setIsManualInputActive(true);
};

const closeManualInput = () => {
setIsManualInputActive(false);
knobContainerRef.current?.focus(); // Re-focus back on the knob container
};

const value01 = mapTo01(value, min, max);
const valueText = displayValueFn(value);

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

const changeValueTo = (newValue01: number): void => {
onChange(mapFrom01(newValue01, min, max));
const changeValueTo = (newValue: number): void => {
onChange(clamp(newValue, min, max));
};

const changeValue01To = (newValue01: number): void => {
changeValueTo(mapFrom01(newValue01, min, max));
};

const changeValueBy = (diff01: number): void => {
const newValue01 = clamp01(value01 + diff01);
changeValueTo(newValue01);
const changeValue01By = (diff01: number): void => {
changeValue01To(clamp01(value01 + diff01));
};

const onKeyDown: React.KeyboardEventHandler<HTMLDivElement> = ({code}) => {
const onKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (event) => {
const {code, key} = event;

if (code === keyCodes.arrowLeft || code === keyCodes.arrowDown) {
changeValueBy(-0.01);
changeValue01By(-0.01);
return;
}

if (code === keyCodes.arrowRight || code === keyCodes.arrowUp) {
changeValueBy(0.01);
changeValue01By(0.01);
return;
}

if (code === keyCodes.backspace || code === keyCodes.delete) {
const defaultValue01 = mapTo01(defaultValue, min, max);
changeValueTo(defaultValue01);
changeValue01To(defaultValue01);
return;
}

if (isNumberKey(key)) {
openManualInput(false);
}
};

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

return (
<div
className={clsx(
'flex select-none flex-col items-center text-xs outline-none focus:outline-dashed focus:outline-1 focus:outline-gray-4',
isLarge ? 'w-20' : 'w-16',
)}
tabIndex={-1} // Making element focusable by mouse / touch (not Tab). Details: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
onKeyDown={onKeyDown}
onPointerDown={(event) => {
// 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.
// 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
event.currentTarget.focus();
}}
>
<label htmlFor={id}>{title}</label>
<div className='relative text-xs'>
<div
id={id}
ref={knobContainerRef}
className={clsx(
'relative touch-none', // It's recommended to disable "touch-action" for use-gesture: https://use-gesture.netlify.app/docs/extras/#touch-action
isLarge ? 'h-16 w-16' : 'h-12 w-12',
'flex select-none flex-col items-center outline-none focus:outline-dashed focus:outline-1 focus:outline-gray-4',
isLarge ? 'w-20' : 'w-16',
)}
role='slider'
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={valueText}
aria-orientation='vertical'
{...bindDrag()}
tabIndex={-1} // Making element focusable by mouse / touch (not Tab). Details: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
onKeyDown={onKeyDown}
onPointerDown={(event) => {
// 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.
// 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
event.currentTarget.focus();
}}
>
<div className='absolute h-full w-full rounded-full bg-gray-3'>
<div
className='absolute h-full w-full'
style={{rotate: `${angle}deg`}}
>
<div className='absolute left-1/2 top-0 h-1/2 w-[2px] -translate-x-1/2 rounded-sm bg-gray-7' />
<label htmlFor={id}>{title}</label>
<div
id={id}
className={clsx(
'relative touch-none', // It's recommended to disable "touch-action" for use-gesture: https://use-gesture.netlify.app/docs/extras/#touch-action
isLarge ? 'h-16 w-16' : 'h-12 w-12',
)}
role='slider'
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={valueText}
aria-orientation='vertical'
{...bindDrag()}
>
<div className='absolute h-full w-full rounded-full bg-gray-3'>
<div
className='absolute h-full w-full'
style={{rotate: `${angle}deg`}}
>
<div className='absolute left-1/2 top-0 h-1/2 w-[2px] -translate-x-1/2 rounded-sm bg-gray-7' />
</div>
</div>
</div>
<label
htmlFor={id}
onClick={() => {
openManualInput(true);
}}
>
{valueText}
</label>
</div>
<label htmlFor={id}>{valueText}</label>
{isManualInputActive && (
<ManualInput
initialValue={manualInputInitialValue}
onCancel={closeManualInput}
onSubmit={(newValue) => {
closeManualInput();
changeValueTo(fromManualInputFn(newValue));
}}
/>
)}
</div>
);
}

type ManualInputProps = {
initialValue?: number;
onCancel: () => void;
onSubmit: (newValue: number) => void;
};

function ManualInput({initialValue, onCancel, onSubmit}: ManualInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus(); // Focus on the input when it's mounted
}, []);

const isCancelledRef = useRef<boolean>(false);

const submit = () => {
if (isCancelledRef.current) return;
onSubmit(Number(inputRef.current?.value));
};

return (
<form
noValidate
className='absolute inset-x-0 bottom-0 w-full'
onSubmit={(event) => {
event.preventDefault(); // Prevent standard form submission behavior
submit();
}}
>
<input
ref={inputRef}
defaultValue={initialValue}
type='number'
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'
onBlur={submit}
onKeyDown={(event) => {
// Prevent standard input behaviour when it's being changed on arrow up/down press
if (
event.code === keyCodes.arrowDown ||
event.code === keyCodes.arrowUp
) {
event.preventDefault();
return;
}

// Cancel on escape
if (event.code === keyCodes.escape) {
isCancelledRef.current = true;
onCancel();
}
}}
/>
</form>
);
}
36 changes: 34 additions & 2 deletions src/components/ui/KnobAdr.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import {Knob, type KnobProps} from './Knob';
import {NormalisableRange} from '@/utils/math';
import {NormalisableRange, round} from '@/utils/math';
import {useConst} from '@/components/hooks/useConst';

export type KnobAdrProps = Omit<
KnobProps,
'min' | 'max' | 'displayValueFn' | 'mapTo01' | 'mapFrom01'
| 'min'
| 'max'
| 'displayValueFn'
| 'toManualInputFn'
| 'fromManualInputFn'
| 'mapTo01'
| 'mapFrom01'
>;

export function KnobAdr(props: KnobAdrProps) {
Expand All @@ -20,6 +26,8 @@ export function KnobAdr(props: KnobAdrProps) {
min={min}
max={max}
displayValueFn={displayValueFn}
toManualInputFn={toManualInputFn}
fromManualInputFn={fromManualInputFn}
mapTo01={mapTo01}
mapFrom01={mapFrom01}
{...props}
Expand Down Expand Up @@ -48,3 +56,27 @@ const displayValueFn = (s: number) => {

return `${s.toFixed(1)} s`;
};

const toManualInputFn = (s: number): number => {
const ms = s * 1000;

if (ms < 10) {
return round(ms, 2);
}

if (ms < 100) {
return round(ms, 1);
}

if (ms < 1000) {
return round(ms);
}

if (ms < 10000) {
return round(ms, -1);
}

return round(ms, -2);
};

const fromManualInputFn = (ms: number): number => ms / 1000;
Loading

0 comments on commit ae7c154

Please sign in to comment.