diff --git a/assets/icons/functionicon.svg b/assets/icons/functionicon.svg new file mode 100644 index 0000000..426d23b --- /dev/null +++ b/assets/icons/functionicon.svg @@ -0,0 +1,8 @@ + + + diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index fce4ef2..e9e4d54 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -417,3 +417,32 @@ body { .ant-badge .ant-badge-count-sm { font-size: 10px; } + +.input-number-cal { + position: relative; +} + +.input-number-cal .ant-input-suffix { + margin-right: 28px; +} + +.input-number-cal .ant-input-outlined { + border-color: var(--color-purple); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-purple) 31%, transparent); +} + +.input-number-cal .ant-input { + font-family: 'DM Mono'; + font-weight: 400; +} + +.input-number-cal-icon { + position: absolute; + right: 10px; + top: 0; + bottom: 1px; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; +} diff --git a/src/components/Dashboard/common/InputNumberCal.jsx b/src/components/Dashboard/common/InputNumberCal.jsx new file mode 100644 index 0000000..2f2523a --- /dev/null +++ b/src/components/Dashboard/common/InputNumberCal.jsx @@ -0,0 +1,167 @@ +import { useState, useRef } from 'react' +import { Input, InputNumber } from 'antd' +import PropTypes from 'prop-types' +import FunctionIcon from '../../Icons/FunctionIcon' + +const OPERATOR_KEYS = ['+', '-', '*', '/'] + +/** + * 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, + ...rest +}) => { + const [isExprMode, setIsExprMode] = useState(false) + const [exprValue, setExprValue] = useState('') + const inputRef = useRef(null) + + const switchToExprMode = (initialValue) => { + setIsExprMode(true) + setExprValue(initialValue) + setTimeout(() => { + //inputRef.current?.focus() + const input = inputRef.current.getElementsByTagName('input')[0] + if (input) { + 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 handleInputKeyDown = (e) => { + if (e.key === '=') { + e.preventDefault() + const result = safeEval(exprValue) + if (result != null) { + exitExprMode(result) + } + } + } + + 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 ( +