- 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.
272 lines
6.6 KiB
JavaScript
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
|