Tom Butcher 1597370a16 Enhance InputNumberCal Component with Expression Highlighting and Improved Layout
- Added expression highlighting functionality to the InputNumberCal component, allowing operators and brackets to be visually distinct.
- Implemented layout synchronization for highlighted expressions to match input field dimensions.
- Updated styles in App.css for dropdown and input number components to improve visual hierarchy and user experience.
- Refactored input handling to support expression mode with keyboard interactions for better usability.
2026-09-01 18:44:31 +01:00

272 lines
6.6 KiB
JavaScript

import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { Input, InputNumber } from 'antd'
import PropTypes from 'prop-types'
import FunctionIcon from '../../Icons/FunctionIcon'
const OPERATOR_KEYS = ['+', '-', '*', '/']
const OPERATORS = new Set(OPERATOR_KEYS)
const BRACKETS = new Set(['(', ')'])
const tokenizeExpr = (raw = '') => {
const value = String(raw)
const tokens = []
let text = ''
for (let i = 0; i < value.length; i += 1) {
const char = value[i]
if (OPERATORS.has(char) || BRACKETS.has(char)) {
if (text) {
tokens.push({ type: 'text', value: text })
text = ''
}
tokens.push({
type: OPERATORS.has(char) ? 'operator' : 'bracket',
value: char
})
} else {
text += char
}
}
if (text) tokens.push({ type: 'text', value: text })
return tokens
}
const renderHighlightedExpr = (raw = '') =>
tokenizeExpr(raw).map((token, index) => {
if (token.type === 'operator') {
return (
<span key={index} className='input-number-cal-operator'>
{token.value}
</span>
)
}
if (token.type === 'bracket') {
return (
<span key={index} className='input-number-cal-bracket'>
{token.value}
</span>
)
}
return <span key={index}>{token.value}</span>
})
/**
* Safely evaluate a math expression. Only allows numbers and +, -, *, /
*/
function safeEval(expr) {
const sanitized = String(expr)
.replace(/\s/g, '')
.replace(/[^0-9+\-*/().]/g, '')
if (!sanitized) return null
try {
const fn = new Function(`return (${sanitized})`)
const result = fn()
return typeof result === 'number' && Number.isFinite(result) ? result : null
} catch {
return null
}
}
const InputNumberCal = ({
value,
onChange,
onBlur,
min,
max,
prefix,
suffix,
placeholder,
disabled,
style,
...rest
}) => {
const [isExprMode, setIsExprMode] = useState(false)
const [exprValue, setExprValue] = useState('')
const wrapperRef = useRef(null)
const highlightRef = useRef(null)
const inputElRef = useRef(null)
const syncHighlightLayout = useCallback(() => {
const wrapper = wrapperRef.current
const highlight = highlightRef.current
const inputEl = inputElRef.current
if (!wrapper || !highlight || !inputEl) return
const wrapperRect = wrapper.getBoundingClientRect()
const inputRect = inputEl.getBoundingClientRect()
const inputStyle = window.getComputedStyle(inputEl)
highlight.style.top = `${inputRect.top - wrapperRect.top}px`
highlight.style.left = `${inputRect.left - wrapperRect.left}px`
highlight.style.width = `${inputRect.width}px`
highlight.style.height = `${inputRect.height}px`
highlight.style.paddingLeft = inputStyle.paddingLeft
highlight.style.paddingRight = inputStyle.paddingRight
highlight.style.font = inputStyle.font
highlight.style.letterSpacing = inputStyle.letterSpacing
highlight.scrollLeft = inputEl.scrollLeft
}, [])
useLayoutEffect(() => {
if (!isExprMode) return undefined
syncHighlightLayout()
const wrapper = wrapperRef.current
if (!wrapper) return undefined
const observer = new ResizeObserver(syncHighlightLayout)
observer.observe(wrapper)
return () => observer.disconnect()
}, [isExprMode, exprValue, prefix, suffix, syncHighlightLayout])
const switchToExprMode = (initialValue) => {
setIsExprMode(true)
setExprValue(initialValue)
setTimeout(() => {
const input = wrapperRef.current?.getElementsByTagName('input')[0]
input?.focus()
}, 0)
}
const exitExprMode = (result) => {
setIsExprMode(false)
setExprValue('')
if (result != null) {
const clamped =
min != null && result < min
? min
: max != null && result > max
? max
: result
onChange?.(clamped)
}
}
const handleNumberKeyDown = (e) => {
if (OPERATOR_KEYS.includes(e.key)) {
e.preventDefault()
const current = value ?? ''
switchToExprMode(String(current) + e.key)
}
}
const handleInputChange = (e) => {
const next = e.target.value
setExprValue(next)
const num = parseFloat(next)
if (next === '') {
onChange?.(null)
} else if (!next.match(/[+\-*/=]/) && !Number.isNaN(num)) {
onChange?.(num)
}
}
const commitExpr = () => {
const result = safeEval(exprValue)
if (result != null) {
exitExprMode(result)
}
}
const handleInputKeyDown = (e) => {
if (e.key === 'Enter' || e.key === '=') {
e.preventDefault()
commitExpr()
}
}
const handleInputScroll = (e) => {
if (highlightRef.current) {
highlightRef.current.scrollLeft = e.target.scrollLeft
}
}
const handleInputBlur = (e) => {
const expr = exprValue.trim()
if (expr && expr.match(/[+\-*/]/)) {
const result = safeEval(expr)
if (result != null) {
exitExprMode(result)
} else {
exitExprMode(null)
}
} else {
const num = parseFloat(expr)
if (!Number.isNaN(num)) {
exitExprMode(num)
} else {
exitExprMode(null)
}
}
onBlur?.(e)
}
const commonProps = {
prefix,
suffix,
placeholder,
disabled,
min,
max,
...rest
}
if (isExprMode) {
return (
<div className='input-number-cal' ref={wrapperRef} style={style}>
<div
ref={highlightRef}
className='input-number-cal-highlight'
aria-hidden
>
{renderHighlightedExpr(exprValue)}
</div>
<Input
ref={(node) => {
inputElRef.current = node?.input ?? null
}}
className='input-number-cal-expr-input'
value={exprValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
onScroll={handleInputScroll}
{...commonProps}
style={style}
/>
<div className='input-number-cal-icon'>
<FunctionIcon style={{ fontSize: 24 }} />
</div>
</div>
)
}
return (
<InputNumber
{...commonProps}
value={value != null ? value : null}
onChange={onChange}
onBlur={onBlur}
onKeyDown={handleNumberKeyDown}
style={style}
/>
)
}
InputNumberCal.propTypes = {
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
onChange: PropTypes.func,
onBlur: PropTypes.func,
min: PropTypes.number,
max: PropTypes.number,
step: PropTypes.number,
prefix: PropTypes.node,
suffix: PropTypes.node,
placeholder: PropTypes.string,
style: PropTypes.object,
disabled: PropTypes.bool
}
export default InputNumberCal