diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index 523602c8..abaac781 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -750,6 +750,39 @@ body { 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 { margin-right: 28px; } diff --git a/src/components/Dashboard/common/InputNumberCal.jsx b/src/components/Dashboard/common/InputNumberCal.jsx index e069cb6a..ed71fa60 100644 --- a/src/components/Dashboard/common/InputNumberCal.jsx +++ b/src/components/Dashboard/common/InputNumberCal.jsx @@ -1,9 +1,55 @@ -import { useState, useRef } from 'react' +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 ( + + {token.value} + + ) + } + if (token.type === 'bracket') { + return ( + + {token.value} + + ) + } + return {token.value} + }) /** * Safely evaluate a math expression. Only allows numbers and +, -, *, / @@ -32,21 +78,54 @@ const InputNumberCal = ({ suffix, placeholder, disabled, + style, ...rest }) => { const [isExprMode, setIsExprMode] = useState(false) 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) => { setIsExprMode(true) setExprValue(initialValue) setTimeout(() => { - //inputRef.current?.focus() - const input = inputRef.current.getElementsByTagName('input')[0] - if (input) { - input.focus() - } + const input = wrapperRef.current?.getElementsByTagName('input')[0] + input?.focus() }, 0) } @@ -83,13 +162,23 @@ const InputNumberCal = ({ } } + const commitExpr = () => { + const result = safeEval(exprValue) + if (result != null) { + exitExprMode(result) + } + } + const handleInputKeyDown = (e) => { - if (e.key === '=') { + if (e.key === 'Enter' || e.key === '=') { e.preventDefault() - const result = safeEval(exprValue) - if (result != null) { - exitExprMode(result) - } + commitExpr() + } + } + + const handleInputScroll = (e) => { + if (highlightRef.current) { + highlightRef.current.scrollLeft = e.target.scrollLeft } } @@ -125,13 +214,26 @@ const InputNumberCal = ({ if (isExprMode) { return ( -
+
+
+ {renderHighlightedExpr(exprValue)} +
{ + inputElRef.current = node?.input ?? null + }} + className='input-number-cal-expr-input' value={exprValue} onChange={handleInputChange} onKeyDown={handleInputKeyDown} onBlur={handleInputBlur} + onScroll={handleInputScroll} {...commonProps} + style={style} />
@@ -147,6 +249,7 @@ const InputNumberCal = ({ onChange={onChange} onBlur={onBlur} onKeyDown={handleNumberKeyDown} + style={style} /> ) } @@ -161,6 +264,7 @@ InputNumberCal.propTypes = { prefix: PropTypes.node, suffix: PropTypes.node, placeholder: PropTypes.string, + style: PropTypes.object, disabled: PropTypes.bool }