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.
This commit is contained in:
Tom Butcher 2026-09-01 18:44:31 +01:00
parent b590890bd3
commit 1597370a16
2 changed files with 150 additions and 13 deletions

View File

@ -750,6 +750,39 @@ body {
position: relative; position: relative;
} }
.input-number-cal-highlight {
position: absolute;
display: flex;
align-items: center;
overflow: hidden;
white-space: pre;
pointer-events: none;
z-index: 0;
color: var(--color-text);
}
.input-number-cal-operator {
color: var(--color-purple);
}
.input-number-cal-bracket {
color: var(--color-primary);
}
.input-number-cal-expr-input,
.input-number-cal-expr-input.ant-input-affix-wrapper {
position: relative;
z-index: 1;
background: transparent !important;
}
.input-number-cal-expr-input.ant-input,
.input-number-cal-expr-input .ant-input {
background: transparent !important;
color: transparent !important;
caret-color: var(--color-text);
}
.input-number-cal .ant-input-suffix { .input-number-cal .ant-input-suffix {
margin-right: 28px; margin-right: 28px;
} }

View File

@ -1,9 +1,55 @@
import { useState, useRef } from 'react' import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { Input, InputNumber } from 'antd' import { Input, InputNumber } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import FunctionIcon from '../../Icons/FunctionIcon' import FunctionIcon from '../../Icons/FunctionIcon'
const OPERATOR_KEYS = ['+', '-', '*', '/'] 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 +, -, *, / * Safely evaluate a math expression. Only allows numbers and +, -, *, /
@ -32,21 +78,54 @@ const InputNumberCal = ({
suffix, suffix,
placeholder, placeholder,
disabled, disabled,
style,
...rest ...rest
}) => { }) => {
const [isExprMode, setIsExprMode] = useState(false) const [isExprMode, setIsExprMode] = useState(false)
const [exprValue, setExprValue] = useState('') const [exprValue, setExprValue] = useState('')
const inputRef = useRef(null) 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) => { const switchToExprMode = (initialValue) => {
setIsExprMode(true) setIsExprMode(true)
setExprValue(initialValue) setExprValue(initialValue)
setTimeout(() => { setTimeout(() => {
//inputRef.current?.focus() const input = wrapperRef.current?.getElementsByTagName('input')[0]
const input = inputRef.current.getElementsByTagName('input')[0] input?.focus()
if (input) {
input.focus()
}
}, 0) }, 0)
} }
@ -83,13 +162,23 @@ const InputNumberCal = ({
} }
} }
const commitExpr = () => {
const result = safeEval(exprValue)
if (result != null) {
exitExprMode(result)
}
}
const handleInputKeyDown = (e) => { const handleInputKeyDown = (e) => {
if (e.key === '=') { if (e.key === 'Enter' || e.key === '=') {
e.preventDefault() e.preventDefault()
const result = safeEval(exprValue) commitExpr()
if (result != null) { }
exitExprMode(result) }
}
const handleInputScroll = (e) => {
if (highlightRef.current) {
highlightRef.current.scrollLeft = e.target.scrollLeft
} }
} }
@ -125,13 +214,26 @@ const InputNumberCal = ({
if (isExprMode) { if (isExprMode) {
return ( return (
<div className='input-number-cal' ref={inputRef}> <div className='input-number-cal' ref={wrapperRef} style={style}>
<div
ref={highlightRef}
className='input-number-cal-highlight'
aria-hidden
>
{renderHighlightedExpr(exprValue)}
</div>
<Input <Input
ref={(node) => {
inputElRef.current = node?.input ?? null
}}
className='input-number-cal-expr-input'
value={exprValue} value={exprValue}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur} onBlur={handleInputBlur}
onScroll={handleInputScroll}
{...commonProps} {...commonProps}
style={style}
/> />
<div className='input-number-cal-icon'> <div className='input-number-cal-icon'>
<FunctionIcon style={{ fontSize: 24 }} /> <FunctionIcon style={{ fontSize: 24 }} />
@ -147,6 +249,7 @@ const InputNumberCal = ({
onChange={onChange} onChange={onChange}
onBlur={onBlur} onBlur={onBlur}
onKeyDown={handleNumberKeyDown} onKeyDown={handleNumberKeyDown}
style={style}
/> />
) )
} }
@ -161,6 +264,7 @@ InputNumberCal.propTypes = {
prefix: PropTypes.node, prefix: PropTypes.node,
suffix: PropTypes.node, suffix: PropTypes.node,
placeholder: PropTypes.string, placeholder: PropTypes.string,
style: PropTypes.object,
disabled: PropTypes.bool disabled: PropTypes.bool
} }