Spaces:
Running
Running
| import { useEffect, useRef, useState, useCallback } from 'react' | |
| import X from 'lucide-react/dist/esm/icons/x' | |
| import { DEG_TO_RAD, isOperatorTail, tokenizeExpression, formatResult, evaluateExpression } from '../../utils/calculatorEngine' | |
| const Btn = ({ label, onClick, type = 'default', wide = false }) => ( | |
| <button onClick={onClick} | |
| className={`calc-btn ${type === 'op' ? 'calc-btn-op' : type === 'eq' ? 'calc-btn-eq' : type === 'clear' ? 'calc-btn-clear' : type === 'back' ? 'calc-btn-backspace' : ''} ${wide ? 'col-span-2' : ''}`}> | |
| {label} | |
| </button> | |
| ) | |
| export default function Calculator({ onClose }) { | |
| const dialogRef = useRef(null) | |
| const expressionRef = useRef('') | |
| const [display, setDisplay] = useState('0') | |
| const [expression, setExpression] = useState('') | |
| const [memory, setMemory] = useState(0) | |
| const [isDeg, setIsDeg] = useState(true) | |
| const [waitingForOperand, setWaitingForOperand] = useState(false) | |
| const getAngle = (val) => isDeg ? val * DEG_TO_RAD : val | |
| const setExpressionText = useCallback((next) => { | |
| const value = typeof next === 'function' ? next(expressionRef.current) : next | |
| expressionRef.current = value | |
| setExpression(value) | |
| }, []) | |
| useEffect(() => { | |
| const firstButton = dialogRef.current?.querySelector('button') | |
| firstButton?.focus() | |
| }, []) | |
| const inputDigit = (digit) => { | |
| if (waitingForOperand) { | |
| setDisplay(String(digit)) | |
| setWaitingForOperand(false) | |
| } else { | |
| setDisplay(display === '0' ? String(digit) : display + digit) | |
| } | |
| } | |
| const inputDecimal = () => { | |
| if (waitingForOperand) { setDisplay('0.'); setWaitingForOperand(false); return } | |
| if (!display.includes('.')) setDisplay(display + '.') | |
| } | |
| const handleOperator = (op) => { | |
| setExpressionText(prev => { | |
| const trimmed = prev.trim() | |
| if (!trimmed) return `${display} ${op}` | |
| if (isOperatorTail(trimmed)) return `${trimmed.slice(0, trimmed.lastIndexOf(' ')).trim()} ${op}` | |
| if (trimmed.endsWith(')')) return `${trimmed} ${op}` | |
| return `${trimmed} ${display} ${op}` | |
| }) | |
| setWaitingForOperand(true) | |
| } | |
| const inputOpenParen = () => { | |
| setExpressionText(prev => { | |
| const trimmed = prev.trim() | |
| if (!trimmed) return '(' | |
| if (isOperatorTail(trimmed) || trimmed.endsWith('(')) return `${trimmed} (` | |
| return `${trimmed} ${display} × (` | |
| }) | |
| setDisplay('0') | |
| setWaitingForOperand(true) | |
| } | |
| const inputCloseParen = () => { | |
| const tokens = tokenizeExpression(expressionRef.current) | |
| const openCount = tokens.filter(token => token === '(').length | |
| const closeCount = tokens.filter(token => token === ')').length | |
| if (openCount <= closeCount) return | |
| setExpressionText(prev => { | |
| const trimmed = prev.trim() | |
| if (!trimmed || trimmed.endsWith('(')) return trimmed | |
| if (trimmed.endsWith(')')) return `${trimmed} )` | |
| return `${trimmed} ${display} )` | |
| }) | |
| setWaitingForOperand(false) | |
| } | |
| const calculate = () => { | |
| if (!expressionRef.current) return | |
| try { | |
| const trimmed = expressionRef.current.trim() | |
| const finalExpression = trimmed.endsWith(')') ? trimmed : `${trimmed} ${display}` | |
| setDisplay(formatResult(evaluateExpression(finalExpression))) | |
| setExpressionText('') | |
| setWaitingForOperand(true) | |
| } catch { setDisplay('Error') } | |
| } | |
| const sciFunc = (fn) => { | |
| const val = parseFloat(display) | |
| let result | |
| try { | |
| switch (fn) { | |
| case 'sin': result = Math.sin(getAngle(val)); break | |
| case 'cos': result = Math.cos(getAngle(val)); break | |
| case 'tan': result = Math.tan(getAngle(val)); break | |
| case 'sin⁻¹': result = isDeg ? Math.asin(val) / DEG_TO_RAD : Math.asin(val); break | |
| case 'cos⁻¹': result = isDeg ? Math.acos(val) / DEG_TO_RAD : Math.acos(val); break | |
| case 'tan⁻¹': result = isDeg ? Math.atan(val) / DEG_TO_RAD : Math.atan(val); break | |
| case 'log': result = Math.log10(val); break | |
| case 'ln': result = Math.log(val); break | |
| case 'log₂': result = Math.log2(val); break | |
| case '√': result = Math.sqrt(val); break | |
| case 'x²': result = val * val; break | |
| case 'x³': result = val * val * val; break | |
| case '1/x': result = 1 / val; break | |
| case 'n!': result = factorial(val); break | |
| case 'eˣ': result = Math.exp(val); break | |
| case '10ˣ': result = Math.pow(10, val); break | |
| case '|x|': result = Math.abs(val); break | |
| case 'π': setDisplay(String(Math.PI)); return | |
| case 'e': setDisplay(String(Math.E)); return | |
| default: return | |
| } | |
| setDisplay(formatResult(result)) | |
| setWaitingForOperand(true) | |
| } catch { setDisplay('Error') } | |
| } | |
| const factorial = (n) => { | |
| if (n < 0 || !Number.isInteger(n)) return NaN | |
| if (n === 0 || n === 1) return 1 | |
| let r = 1; for (let i = 2; i <= n; i++) r *= i; return r | |
| } | |
| const clear = () => { setDisplay('0'); setExpressionText(''); setWaitingForOperand(false) } | |
| const backspace = () => { setDisplay(display.length > 1 ? display.slice(0, -1) : '0') } | |
| const toggleSign = () => setDisplay(String(parseFloat(display) * -1)) | |
| const handleDialogKeyDown = useCallback((event) => { | |
| if (event.key === 'Escape') { | |
| event.preventDefault() | |
| onClose() | |
| return | |
| } | |
| if (event.key !== 'Tab') return | |
| const focusable = Array.from( | |
| dialogRef.current?.querySelectorAll('button:not([disabled])') || [] | |
| ) | |
| if (focusable.length === 0) return | |
| const first = focusable[0] | |
| const last = focusable.at(-1) | |
| if (event.shiftKey && document.activeElement === first) { | |
| event.preventDefault() | |
| last.focus() | |
| } else if (!event.shiftKey && document.activeElement === last) { | |
| event.preventDefault() | |
| first.focus() | |
| } | |
| }, [onClose]) | |
| return ( | |
| <div ref={dialogRef} | |
| className="fixed z-50 shadow-2xl rounded-lg overflow-hidden select-none sm:w-[320px] left-4 right-4 sm:left-auto sm:right-4" | |
| role="dialog" aria-modal="true" aria-labelledby="calculator-title" | |
| onKeyDown={handleDialogKeyDown} | |
| style={{ top: '70px', background: 'var(--bg-card)', border: '1px solid var(--border)' }}> | |
| {/* Header */} | |
| <div className="flex items-center justify-between px-3 py-2 border-b" | |
| style={{ background: 'var(--bg-panel)', borderColor: 'var(--border)' }}> | |
| <span id="calculator-title" className="text-sm font-semibold" style={{ color: 'var(--text)' }}>Scientific Calculator</span> | |
| <div className="flex items-center gap-3"> | |
| <div className="flex items-center gap-1 text-xs" style={{ color: 'var(--text-muted)' }}> | |
| <button onClick={() => setIsDeg(true)} | |
| className={`px-2 py-0.5 rounded ${isDeg ? 'bg-sky-600 text-white' : ''}`}>Deg</button> | |
| <button onClick={() => setIsDeg(false)} | |
| className={`px-2 py-0.5 rounded ${!isDeg ? 'bg-sky-600 text-white' : ''}`}>Rad</button> | |
| </div> | |
| <button onClick={onClose} aria-label="Close Calculator" className="text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:text-slate-200"><X size={16} /></button> | |
| </div> | |
| </div> | |
| {/* Display */} | |
| <div className="px-3 py-2 text-right" style={{ background: 'var(--bg)' }}> | |
| <div className="text-xs h-4 mb-1" style={{ color: 'var(--text-muted)' }}>{expression}</div> | |
| <div className="text-2xl font-mono font-bold truncate" style={{ color: 'var(--text)' }}>{display}</div> | |
| </div> | |
| {/* Memory row */} | |
| <div className="grid grid-cols-5 gap-1 px-2 pb-1"> | |
| {[ | |
| { l: 'MC', fn: () => setMemory(0) }, | |
| { l: 'MR', fn: () => { setDisplay(String(memory)); setWaitingForOperand(false) } }, | |
| { l: 'MS', fn: () => setMemory(parseFloat(display)) }, | |
| { l: 'M+', fn: () => setMemory(m => m + parseFloat(display)) }, | |
| { l: 'M-', fn: () => setMemory(m => m - parseFloat(display)) }, | |
| ].map(({ l, fn }) => ( | |
| <button key={l} onClick={fn} | |
| className="calc-btn text-xs h-7" style={{ background: '#1a3060', color: '#93c5fd' }}>{l}</button> | |
| ))} | |
| </div> | |
| {/* Scientific functions */} | |
| <div className="grid grid-cols-5 gap-1 px-2 pb-1"> | |
| {['sin','cos','tan','log','ln', | |
| 'sin⁻¹','cos⁻¹','tan⁻¹','log₂','eˣ', | |
| 'x²','x³','√','10ˣ','1/x', | |
| 'n!','|x|','π','e','%'].map(fn => ( | |
| <button key={fn} onClick={() => sciFunc(fn)} | |
| className="calc-btn text-xs h-8" style={{ background: 'var(--bg-panel)', color: 'var(--brand-light)' }}>{fn}</button> | |
| ))} | |
| </div> | |
| {/* Main keypad */} | |
| <div className="grid grid-cols-5 gap-1 px-2 pb-2"> | |
| <Btn label="C" onClick={clear} type="clear" /> | |
| <Btn label="+/-" onClick={toggleSign} /> | |
| <Btn label="←" onClick={backspace} type="back" /> | |
| <Btn label="xʸ" onClick={() => handleOperator('xʸ')} type="op" /> | |
| <Btn label="÷" onClick={() => handleOperator('÷')} type="op" /> | |
| <Btn label="7" onClick={() => inputDigit('7')} /> | |
| <Btn label="8" onClick={() => inputDigit('8')} /> | |
| <Btn label="9" onClick={() => inputDigit('9')} /> | |
| <Btn label="×" onClick={() => handleOperator('×')} type="op" /> | |
| <Btn label="(" onClick={inputOpenParen} /> | |
| <Btn label="4" onClick={() => inputDigit('4')} /> | |
| <Btn label="5" onClick={() => inputDigit('5')} /> | |
| <Btn label="6" onClick={() => inputDigit('6')} /> | |
| <Btn label="-" onClick={() => handleOperator('-')} type="op" /> | |
| <Btn label=")" onClick={inputCloseParen} /> | |
| <Btn label="1" onClick={() => inputDigit('1')} /> | |
| <Btn label="2" onClick={() => inputDigit('2')} /> | |
| <Btn label="3" onClick={() => inputDigit('3')} /> | |
| <Btn label="+" onClick={() => handleOperator('+')} type="op" /> | |
| <Btn label="%" onClick={() => handleOperator('%')} type="op" /> | |
| <Btn label="0" onClick={() => inputDigit('0')} wide /> | |
| <Btn label="." onClick={inputDecimal} /> | |
| <Btn label="=" onClick={calculate} type="eq" wide /> | |
| </div> | |
| </div> | |
| ) | |
| } | |