InvokeAI/invokeai/frontend/web/src/common/components/IAINumberInput.tsx
2023-05-15 17:45:05 +10:00

145 lines
4.0 KiB
TypeScript

import {
FormControl,
FormControlProps,
FormLabel,
FormLabelProps,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputFieldProps,
NumberInputProps,
NumberInputStepper,
NumberInputStepperProps,
Tooltip,
TooltipProps,
} from '@chakra-ui/react';
import { stopPastePropagation } from 'common/util/stopPastePropagation';
import { clamp } from 'lodash-es';
import { FocusEvent, memo, useEffect, useState } from 'react';
const numberStringRegex = /^-?(0\.)?\.?$/;
interface Props extends Omit<NumberInputProps, 'onChange'> {
label?: string;
showStepper?: boolean;
value?: number;
onChange: (v: number) => void;
min: number;
max: number;
clamp?: boolean;
isInteger?: boolean;
formControlProps?: FormControlProps;
formLabelProps?: FormLabelProps;
numberInputProps?: NumberInputProps;
numberInputFieldProps?: NumberInputFieldProps;
numberInputStepperProps?: NumberInputStepperProps;
tooltipProps?: Omit<TooltipProps, 'children'>;
}
/**
* Customized Chakra FormControl + NumberInput multi-part component.
*/
const IAINumberInput = (props: Props) => {
const {
label,
isDisabled = false,
showStepper = true,
isInvalid,
value,
onChange,
min,
max,
isInteger = true,
formControlProps,
formLabelProps,
numberInputFieldProps,
numberInputStepperProps,
tooltipProps,
...rest
} = props;
/**
* Using a controlled input with a value that accepts decimals needs special
* handling. If the user starts to type in "1.5", by the time they press the
* 5, the value has been parsed from "1." to "1" and they end up with "15".
*
* To resolve this, this component keeps a the value as a string internally,
* and the UI component uses that. When a change is made, that string is parsed
* as a number and given to the `onChange` function.
*/
const [valueAsString, setValueAsString] = useState<string>(String(value));
/**
* When `value` changes (e.g. from a diff source than this component), we need
* to update the internal `valueAsString`, but only if the actual value is different
* from the current value.
*/
useEffect(() => {
if (
!valueAsString.match(numberStringRegex) &&
value !== Number(valueAsString)
) {
setValueAsString(String(value));
}
}, [value, valueAsString]);
const handleOnChange = (v: string) => {
setValueAsString(v);
// This allows negatives and decimals e.g. '-123', `.5`, `-0.2`, etc.
if (!v.match(numberStringRegex)) {
// Cast the value to number. Floor it if it should be an integer.
onChange(isInteger ? Math.floor(Number(v)) : Number(v));
}
};
/**
* Clicking the steppers allows the value to go outside bounds; we need to
* clamp it on blur and floor it if needed.
*/
const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
const clamped = clamp(
isInteger ? Math.floor(Number(e.target.value)) : Number(e.target.value),
min,
max
);
setValueAsString(String(clamped));
onChange(clamped);
};
return (
<Tooltip {...tooltipProps}>
<FormControl
isDisabled={isDisabled}
isInvalid={isInvalid}
{...formControlProps}
>
{label && <FormLabel {...formLabelProps}>{label}</FormLabel>}
<NumberInput
value={valueAsString}
min={min}
max={max}
keepWithinRange={true}
clampValueOnBlur={false}
onChange={handleOnChange}
onBlur={handleBlur}
{...rest}
onPaste={stopPastePropagation}
>
<NumberInputField {...numberInputFieldProps} />
{showStepper && (
<NumberInputStepper>
<NumberIncrementStepper {...numberInputStepperProps} />
<NumberDecrementStepper {...numberInputStepperProps} />
</NumberInputStepper>
)}
</NumberInput>
</FormControl>
</Tooltip>
);
};
export default memo(IAINumberInput);