1
0
Fork 0
freesewing/sites/shared/components/workbench/menus/shared/inputs.mjs

489 lines
16 KiB
JavaScript

import { useCallback, useMemo, useState, useEffect, useRef } from 'react'
import {
round,
measurementAsMm,
measurementAsUnits,
formatFraction128,
fractionToDecimal,
} from 'shared/utils.mjs'
import { ChoiceButton } from 'shared/components/choice-button.mjs'
import debounce from 'lodash.debounce'
/*******************************************************************************************
* This file contains the base components to be used by inputs in menus in the workbench
* For the purposes of our menus, we have two main types:
* Sliders for changing numbers
* Lists for changing everything else
*
* Inputs that deal with more specific use cases should wrap one of the above base inputs
*******************************************************************************************/
/** Regex to validate that an input is a number */
const numberInputMatchers = {
0: /^-?[0-9]*[.,eE]?[0-9]+$/, // match a single decimal separator
1: /^-?[0-9]*(\s?[0-9]+\/|[.,eE])?[0-9]+$/, // match a single decimal separator or fraction
}
/**
* Validate and parse a value that should be a number
* @param {any} val the value to validate
* @param {Boolean} allowFractions should fractions be considered valid input?
* @param {Number} min the minimum allowable value
* @param {Number} max the maximum allowable value
* @return {null|false|Number} null if the value is empty,
* false if the value is invalid,
* or the value parsed to a number if it is valid
*/
const validateVal = (val, allowFractions = true, min = -Infinity, max = Infinity) => {
// if it's empty, we're neutral
if (typeof val === 'undefined' || val === '') return null
// make sure it's a string
val = ('' + val).trim()
// get the appropriate match pattern and check for a match
const matchPattern = numberInputMatchers[Number(allowFractions)]
if (!val.match(matchPattern)) return false
// replace comma with period
const parsedVal = val.replace(',', '.')
// if fractions are allowed, parse for fractions, otherwise use the number as a value
const useVal = allowFractions ? fractionToDecimal(parsedVal) : parsedVal
// check that it's a number and it's in the range
if (isNaN(useVal) || useVal > max || useVal < min) return false
// all checks passed. return the parsed value
return useVal
}
/**
* A number input that accepts comma or period decimal separators.
* Because our use case is almost never going to include thousands, we're using a very simple way of accepting commas:
* The validator checks for the presence of a single comma or period followed by numbers
* The parser replaces a single comma with a period
*
* optionally accepts fractions
* @param {Number} options.val the value of the input
* @param {Function} options.onUpdate a function to handle when the value is updated to a valid value
* @param {Boolean} options.fractions should the input allow fractional input
*/
export const NumberInput = ({
value,
onUpdate,
onMount,
className,
fractions = true,
min = -Infinity,
max = Infinity,
}) => {
const valid = useRef(validateVal(value, fractions, min, max))
// this is the change handler that will be debounced by the debounce handler
// we check validity inside this debounced function because
// we need to call the debounce handler on change regardless of validity
// if we don't, the displayed value won't update
const handleChange = useCallback(
(newVal) => {
// only actually update if the value is valid
if (typeof onUpdate === 'function') {
onUpdate(valid.current, newVal)
}
},
[onUpdate, valid]
)
// get a debounce handler
const { debouncedHandleChange, displayVal } = useDebouncedHandlers({ handleChange, val: value })
// onChange
const onChange = useCallback(
(evt) => {
const newVal = evt.target.value
// set validity so it will display
valid.current = validateVal(newVal, fractions, min, max)
// handle the change
debouncedHandleChange(newVal)
},
[debouncedHandleChange, fractions, min, max, valid]
)
useEffect(() => {
if (typeof onMount === 'function') {
onMount(valid.current)
}
}, [onMount, valid])
return (
<input
type="text"
inputMode="number"
className={`input input-secondary ${className || 'input-sm grow text-base-content'}
${valid.current === false && 'input-error'}
${valid.current && 'input-success'}
`}
value={displayVal}
onChange={onChange}
/>
)
}
/** A component that shows a number input to edit a value */
const EditCount = (props) => {
const { handleChange } = props
const onUpdate = useCallback(
(validVal) => {
if (validVal !== null && validVal !== false) handleChange(validVal)
},
[handleChange]
)
return (
<div className="form-control mb-2 w-full">
<label className="label">
<span className="label-text text-base-content">{props.min}</span>
<span className="label-text font-bold text-base-content">{props.current}</span>
<span className="label-text text-base-content">{props.max}</span>
</label>
<label className="input-group input-group-sm">
<NumberInput value={props.current} onUpdate={onUpdate} min={props.min} max={props.max} />
<span className="text-base-content font-bold">#</span>
</label>
</div>
)
}
/**
* A hook to get the change handler for an input.
* @param {Number|String|Boolean} options.dflt the default value for the input
* @param {Function} options.updateFunc the onChange
* @param {string} options.name the name of the property being changed
* @return the change handler for the input
*/
const useSharedHandlers = ({ dflt, updateFunc, name }) => {
return useCallback(
(newCurrent) => {
if (newCurrent === dflt) newCurrent = undefined
updateFunc([name], newCurrent)
},
[dflt, updateFunc, name]
)
}
/** get the configuration that allows a boolean value to use the list input */
const useBoolConfig = (name, config) => {
return useMemo(
() => ({
list: [false, true],
choiceTitles: {
false: `${name}No`,
true: `${name}Yes`,
},
valueTitles: {
false: 'no',
true: 'yes',
},
...config,
}),
[name, config]
)
}
/** a toggle input for list/boolean values */
export const ListToggle = ({ config, changed, updateFunc, name }) => {
const boolConfig = useBoolConfig(name, config)
const handleChange = useSharedHandlers({ dflt: boolConfig.dflt, updateFunc, name })
const dfltIndex = boolConfig.list.indexOf(boolConfig.dflt)
const doToggle = () =>
handleChange(boolConfig.list[changed ? dfltIndex : Math.abs(dfltIndex - 1)])
const checked = boolConfig.dflt == false ? changed : !changed
return (
<input
type="checkbox"
className={`toggle ${changed ? 'toggle-accent' : 'toggle-secondary'}`}
checked={checked}
onChange={doToggle}
onClick={(evt) => evt.stopPropagation()}
/>
)
}
/**
* An input for selecting and item from a list
* @param {String} options.name the name of the property this input changes
* @param {Object} options.config configuration for the input
* @param {String|Number} options.current the current value of the input
* @param {Function} options.updateFunc the function called by the event handler to update the value
* @param {Boolean} options.compact include descriptions with the list items?
* @param {Function} options.t translation function
*/
export const ListInput = ({ name, config, current, updateFunc, compact = false, t, changed }) => {
const handleChange = useSharedHandlers({
dflt: config.dflt,
updateFunc,
name,
})
return (
<>
<p>{t(`${name}.d`)}</p>
{config.list.map((entry) => {
const titleKey = config.choiceTitles ? config.choiceTitles[entry] : `${name}.o.${entry}`
return (
<ChoiceButton
key={entry}
title={t(`${titleKey}.t`)}
color={entry === config.dflt ? 'primary' : 'secondary'}
active={changed ? current === entry : entry === config.dflt}
onClick={() => handleChange(entry)}
>
{compact ? null : t(`${titleKey}.d`)}
</ChoiceButton>
)
})}
</>
)
}
/** A boolean version of {@see ListInput} that sets up the necessary configuration */
export const BoolInput = (props) => {
const { name, config } = props
const boolConfig = useBoolConfig(name, config)
return <ListInput {...props} config={boolConfig} />
}
export const useDebouncedHandlers = ({ handleChange = () => {}, val }) => {
// hold onto what we're showing as the value so that the input doesn't look unresponsive
const [displayVal, setDisplayVal] = useState(val)
// the debounce function needs to be it's own memoized value so we can flush it on unmount
const debouncer = useMemo(() => debounce(handleChange, 300), [handleChange])
// this is the change handler
const debouncedHandleChange = useCallback(
(newVal) => {
// always set the display
setDisplayVal(newVal)
// debounce the actual update
debouncer(newVal)
},
[setDisplayVal, debouncer]
)
// immediately call the debounced function on unmount so we don't miss an update
useEffect(() => debouncer.flush, [debouncer])
// set the display val to the current value when it gets changed
useEffect(() => {
setDisplayVal(val)
}, [val])
return { debouncedHandleChange, displayVal }
}
/**
* An input component that uses a slider to change a number value
* @param {String} options.name the name of the property being changed by the input
* @param {Object} options.config configuration for the input
* @param {Number} options.current the current value of the input
* @param {Function} options.updateFunc the function called by the event handler to update the value
* @param {Function} options.t translation function
* @param {Boolean} options.override open the text input to allow override of the slider?
* @param {String} options.suffix a suffix to append to value labels
* @param {Function} options.valFormatter a function that accepts a value and formats it for display as a label
* @param {Function} options.setReset a setter for the reset function on the parent component
*/
export const SliderInput = ({
name,
config,
current,
updateFunc,
t,
override,
suffix = '',
valFormatter = (val) => val,
setReset,
children,
changed,
}) => {
const { max, min } = config
const handleChange = useSharedHandlers({
current,
dflt: config.dflt,
updateFunc,
name,
setReset,
})
const { debouncedHandleChange, displayVal } = useDebouncedHandlers({
handleChange,
val: changed ? current : config.dflt,
})
return (
<>
<p>{t(`${name}.d`)}</p>
<div className="flex flex-row justify-between">
{override ? (
<EditCount
{...{
current: displayVal,
handleChange,
min,
max,
t,
}}
/>
) : (
<>
<span className="opacity-50">
<span dangerouslySetInnerHTML={{ __html: valFormatter(min) + suffix }} />
</span>
<span
className={`font-bold ${
displayVal === config.dflt ? 'text-secondary' : 'text-accent'
}`}
>
<span dangerouslySetInnerHTML={{ __html: valFormatter(displayVal) + suffix }} />
</span>
<span className="opacity-50">
<span dangerouslySetInnerHTML={{ __html: valFormatter(max) + suffix }} />
</span>
</>
)}
</div>
<input
type="range"
{...{ min, max, value: displayVal, step: config.step || 0.1 }}
onChange={(evt) => debouncedHandleChange(evt.target.value)}
className={`
range range-sm mt-1
${changed ? 'range-accent' : 'range-secondary'}
`}
/>
{children}
</>
)
}
/**
* round a value to the correct number of decimal places to display all supplied digits after multiplication
* this is a workaround for floating point errors
* examples:
* roundPct(0.72, 100) === 72
* roundPct(7.5, 0.01) === 0.075
* roundPct(7.50, 0.01) === 0.0750
* @param {Number} num the number to be operated on
* @param {Number} factor the number to multiply by
* @return {Number} the given num multiplied by the factor, rounded appropriately
*/
const roundPct = (num, factor) => {
// stringify
const str = '' + num
// get the index of the decimal point in the number
const decimalIndex = str.indexOf('.')
// get the number of places the factor moves the decimal point
const factorPlaces = factor > 0 ? Math.ceil(Math.log10(factor)) : Math.floor(Math.log10(factor))
// the number of places needed is the number of digits that exist after the decimal minus the number of places the decimal point is being moved
const numPlaces = Math.max(0, str.length - (decimalIndex + factorPlaces))
return round(num * factor, numPlaces)
}
/** A {@see SliderInput} to handle percentage values */
export const PctInput = ({ current, changed, updateFunc, config, ...rest }) => {
const factor = 100
let pctCurrent = changed ? roundPct(current, factor) : current
const pctUpdateFunc = useCallback(
(path, newVal) =>
updateFunc(path, newVal === undefined ? undefined : roundPct(newVal, 1 / factor)),
[updateFunc]
)
return (
<SliderInput
{...{
...rest,
config: { ...config, dflt: roundPct(config.dflt, factor) },
current: pctCurrent,
updateFunc: pctUpdateFunc,
suffix: '%',
valFormatter: round,
changed,
}}
/>
)
}
/** A {@see SliderInput} to handle degree values */
export const DegInput = (props) => {
const { updateFunc } = props
const degUpdateFunc = useCallback(
(path, newVal) => {
updateFunc(path, newVal === undefined ? undefined : Number(newVal))
},
[updateFunc]
)
return <SliderInput {...props} suffix="°" valFormatter={round} updateFunc={degUpdateFunc} />
}
export const MmInput = (props) => {
const { units, updateFunc, current, config } = props
const mmUpdateFunc = useCallback(
(path, newCurrent) => {
const calcCurrent =
typeof newCurrent === 'undefined' ? undefined : measurementAsMm(newCurrent, units)
updateFunc(path, calcCurrent)
},
[updateFunc, units]
)
// add a default step that's appropriate to the unit. can be overwritten by config
const defaultStep = units === 'imperial' ? 0.125 : 0.1
return (
<SliderInput
{...{
...props,
config: {
step: defaultStep,
...config,
dflt: measurementAsUnits(config.dflt, units),
},
current: current === undefined ? undefined : measurementAsUnits(current, units),
updateFunc: mmUpdateFunc,
valFormatter: (val) => (units === 'imperial' ? formatFraction128(val, null) : val),
suffix: units === 'imperial' ? '"' : 'cm',
}}
/>
)
}
/** A placeholder for an input to handle constant values */
export const ConstantInput = ({
type = 'number',
name,
current,
updateFunc,
t,
changed,
config,
}) => (
<>
<p>{t(`${name}.d`)}</p>
<input
type={type}
className={`
input input-bordered w-full text-base-content
input-${changed ? 'secondary' : 'accent'}
`}
value={changed ? current : config.dflt}
onChange={(evt) => updateFunc([name], evt.target.value)}
/>
</>
)