527 lines
18 KiB
JavaScript
527 lines
18 KiB
JavaScript
import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||
import { createCalculator } from '@/util/calc-utils'
|
||
import '@/styles/calc.scss'
|
||
|
||
export const CalculatorInput = forwardRef(
|
||
({ value, onChange, label, options = {}, id, className = 'calculator-input', readOnly = false, placeholder, name='', disabled = false, maxLength = 12 }, ref) => {
|
||
const [showKeypad, setShowKeypad] = useState(false)
|
||
const [displayValue, setDisplayValue] = useState(value || '0')
|
||
const [hasOperation, setHasOperation] = useState(false)
|
||
const calculatorRef = useRef(createCalculator(options))
|
||
const containerRef = useRef(null)
|
||
const inputRef = useRef(null)
|
||
|
||
// 외부 ref와 내부 ref를 동기화
|
||
useEffect(() => {
|
||
if (ref) {
|
||
if (typeof ref === 'function') {
|
||
ref(inputRef.current)
|
||
} else {
|
||
ref.current = inputRef.current
|
||
}
|
||
}
|
||
}, [ref])
|
||
|
||
// Sync displayValue with value prop
|
||
// useEffect(() => {
|
||
// setDisplayValue(value || '0')
|
||
// }, [value])
|
||
|
||
useEffect(() => {
|
||
const newValue = value || '0'
|
||
setDisplayValue(newValue)
|
||
|
||
// 외부에서 value가 변경될 때 계산기 내부 상태도 동기화
|
||
const calculator = calculatorRef.current
|
||
if (calculator) {
|
||
// 연산 중이 아닐 때 외부에서 값이 들어오면 현재 피연산자로 설정
|
||
calculator.currentOperand = newValue.toString()
|
||
calculator.previousOperand = ''
|
||
calculator.operation = undefined
|
||
setHasOperation(false)
|
||
}
|
||
}, [value])
|
||
|
||
// 클릭 외부 감지
|
||
useEffect(() => {
|
||
const handleClickOutside = (event) => {
|
||
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
||
setShowKeypad(false)
|
||
if (hasOperation) {
|
||
// If there's an operation in progress, compute the result when losing focus
|
||
handleCompute()
|
||
}
|
||
}
|
||
}
|
||
|
||
document.addEventListener('mousedown', handleClickOutside)
|
||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||
}, [value, onChange, hasOperation])
|
||
|
||
// 숫자 입력 처리 함수 수정
|
||
const handleNumber = (num) => {
|
||
const calculator = calculatorRef.current
|
||
let newDisplayValue = ''
|
||
|
||
// maxLength 체크
|
||
if (maxLength > 0) {
|
||
const currentLength = (calculator.currentOperand || '').length + (calculator.previousOperand || '').length + (calculator.operation || '').length
|
||
if (currentLength >= maxLength) {
|
||
return
|
||
}
|
||
}
|
||
|
||
// 소수점 이하 2자리 제한 로직 추가
|
||
const shouldPreventInput = (value) => {
|
||
if (!value) return false
|
||
const decimalParts = value.toString().split('.')
|
||
return decimalParts.length > 1 && decimalParts[1].length >= 2
|
||
}
|
||
|
||
// 숫자 추가 함수
|
||
const appendNumber = (current, num) => {
|
||
// maxLength 체크
|
||
if (maxLength > 0 && (current + num).length > maxLength) {
|
||
return current
|
||
}
|
||
// 현재 값이 0이고 소수점이 없을 때 0이 아닌 숫자를 입력하면 대체
|
||
if (current === '0' && num !== '.' && !current.includes('.')) {
|
||
return num.toString()
|
||
}
|
||
// 0. 다음에 0을 입력하는 경우 허용
|
||
if (current === '0' && num === '0') {
|
||
return '0.'
|
||
}
|
||
return current + num
|
||
}
|
||
|
||
if (hasOperation) {
|
||
// 연산자 이후 숫자 입력 시
|
||
if (calculator.shouldResetDisplay) {
|
||
calculator.currentOperand = num.toString()
|
||
calculator.shouldResetDisplay = false
|
||
} else if (num === '.') {
|
||
if (!calculator.currentOperand.includes('.')) {
|
||
calculator.currentOperand = calculator.currentOperand || '0' + '.'
|
||
}
|
||
} else if (!shouldPreventInput(calculator.currentOperand)) {
|
||
calculator.currentOperand = appendNumber(calculator.currentOperand || '0', num)
|
||
}
|
||
|
||
newDisplayValue = calculator.previousOperand + calculator.operation + calculator.currentOperand
|
||
setDisplayValue(newDisplayValue)
|
||
} else {
|
||
// 첫 번째 숫자 입력 시
|
||
if (calculator.shouldResetDisplay) {
|
||
calculator.currentOperand = num.toString()
|
||
calculator.shouldResetDisplay = false
|
||
newDisplayValue = calculator.currentOperand
|
||
setDisplayValue(newDisplayValue)
|
||
if (!hasOperation) {
|
||
onChange(calculator.currentOperand)
|
||
}
|
||
} else if (num === '.') {
|
||
if (!calculator.currentOperand.includes('.')) {
|
||
calculator.currentOperand = (calculator.currentOperand || '0') + '.'
|
||
newDisplayValue = calculator.currentOperand
|
||
setDisplayValue(newDisplayValue)
|
||
if (!hasOperation) {
|
||
onChange(newDisplayValue)
|
||
}
|
||
}
|
||
} else if (!shouldPreventInput(calculator.currentOperand)) {
|
||
calculator.currentOperand = appendNumber(calculator.currentOperand || '0', num)
|
||
newDisplayValue = calculator.currentOperand
|
||
setDisplayValue(newDisplayValue)
|
||
if (!hasOperation) {
|
||
onChange(newDisplayValue)
|
||
}
|
||
}
|
||
// else {
|
||
// calculator.currentOperand = (calculator.currentOperand || '') + num
|
||
// newDisplayValue = calculator.currentOperand
|
||
// setDisplayValue(newDisplayValue)
|
||
// if (!hasOperation) {
|
||
// onChange(newDisplayValue)
|
||
// }
|
||
// }
|
||
}
|
||
|
||
// 커서를 텍스트 끝으로 이동하고 스크롤 처리
|
||
requestAnimationFrame(() => {
|
||
if (inputRef.current) {
|
||
inputRef.current.focus()
|
||
const len = newDisplayValue.length
|
||
inputRef.current.setSelectionRange(len, len)
|
||
// 텍스트 끝으로 스크롤
|
||
inputRef.current.scrollLeft = inputRef.current.scrollWidth
|
||
}
|
||
})
|
||
}
|
||
|
||
// 연산자 처리 함수 수정
|
||
const handleOperation = (operation) => {
|
||
const calculator = calculatorRef.current
|
||
let newDisplayValue = ''
|
||
|
||
// 현재 입력된 값이 없으면 이전 값 사용 (연속 연산 시)
|
||
if (!calculator.currentOperand && calculator.previousOperand) {
|
||
calculator.operation = operation
|
||
newDisplayValue = calculator.previousOperand + operation
|
||
setDisplayValue(newDisplayValue)
|
||
setHasOperation(true)
|
||
} else if (hasOperation) {
|
||
// 이미 연산자가 있는 경우, 계산 실행
|
||
const result = calculator.compute()
|
||
if (result !== undefined) {
|
||
calculator.previousOperand = result.toString()
|
||
calculator.operation = operation
|
||
calculator.currentOperand = ''
|
||
newDisplayValue = calculator.previousOperand + operation
|
||
setDisplayValue(newDisplayValue)
|
||
}
|
||
} else {
|
||
// 첫 번째 연산자 입력 시
|
||
calculator.previousOperand = calculator.currentOperand || '0'
|
||
calculator.operation = operation
|
||
calculator.currentOperand = ''
|
||
setHasOperation(true)
|
||
newDisplayValue = calculator.previousOperand + operation
|
||
setDisplayValue(newDisplayValue)
|
||
}
|
||
|
||
// 커서를 텍스트 끝으로 이동하고 스크롤 처리
|
||
requestAnimationFrame(() => {
|
||
if (inputRef.current) {
|
||
inputRef.current.focus()
|
||
const len = newDisplayValue.length
|
||
inputRef.current.setSelectionRange(len, len)
|
||
// 텍스트 끝으로 스크롤
|
||
inputRef.current.scrollLeft = inputRef.current.scrollWidth
|
||
}
|
||
})
|
||
}
|
||
|
||
// AC 버튼 클릭 핸들러
|
||
const handleClear = () => {
|
||
const calculator = calculatorRef.current
|
||
const newValue = calculator.clear()
|
||
const displayValue = newValue || '0'
|
||
setDisplayValue(displayValue)
|
||
setHasOperation(false)
|
||
onChange(displayValue)
|
||
// 커서를 텍스트 끝으로 이동하고 스크롤 처리
|
||
requestAnimationFrame(() => {
|
||
if (inputRef.current) {
|
||
inputRef.current.focus()
|
||
const len = displayValue.length
|
||
inputRef.current.setSelectionRange(len, len)
|
||
// 텍스트 끝으로 스크롤
|
||
inputRef.current.scrollLeft = inputRef.current.scrollWidth
|
||
}
|
||
})
|
||
}
|
||
|
||
// 계산 실행 함수 수정
|
||
const handleCompute = (fromEnterKey = false) => {
|
||
const calculator = calculatorRef.current
|
||
if (!hasOperation || !calculator.currentOperand) return
|
||
|
||
const result = calculator.compute()
|
||
if (result !== undefined) {
|
||
const resultStr = result.toString()
|
||
setDisplayValue(resultStr)
|
||
setHasOperation(false)
|
||
// Only call onChange with the final result
|
||
onChange(resultStr)
|
||
|
||
// 엔터키로 호출된 경우 포커스 설정하지 않음
|
||
if (!fromEnterKey) {
|
||
requestAnimationFrame(() => {
|
||
if (inputRef.current) {
|
||
inputRef.current.focus()
|
||
const len = resultStr.length
|
||
inputRef.current.setSelectionRange(len, len)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// DEL 버튼 클릭 핸들러
|
||
const handleDelete = () => {
|
||
const calculator = calculatorRef.current
|
||
const newValue = calculator.deleteNumber()
|
||
const displayValue = newValue || '0'
|
||
setDisplayValue(displayValue)
|
||
setHasOperation(!!calculator.operation)
|
||
onChange(displayValue)
|
||
// 커서를 텍스트 끝으로 이동하고 스크롤 처리
|
||
requestAnimationFrame(() => {
|
||
if (inputRef.current) {
|
||
inputRef.current.focus()
|
||
const len = displayValue.length
|
||
inputRef.current.setSelectionRange(len, len)
|
||
// 텍스트 끝으로 스크롤
|
||
inputRef.current.scrollLeft = inputRef.current.scrollWidth
|
||
}
|
||
})
|
||
}
|
||
|
||
// input의 onChange 이벤트 처리 - 허용된 계산기 입력만 처리
|
||
const handleInputChange = (e) => {
|
||
if (readOnly) return
|
||
|
||
const inputValue = e.target.value
|
||
|
||
// 허용된 문자만 필터링 (숫자, 연산자, 소수점)
|
||
const filteredValue = inputValue.replace(/[^0-9+\-×÷.]/g, '')
|
||
|
||
// 연산자 연속 입력 방지
|
||
const lastChar = filteredValue[filteredValue.length - 1]
|
||
const prevChar = filteredValue[filteredValue.length - 2]
|
||
|
||
if (['+', '×', '÷'].includes(lastChar) && ['+', '-', '×', '÷', '.'].includes(prevChar)) {
|
||
// 연산자나 소수점이 연속으로 입력된 경우 이전 문자 유지
|
||
return
|
||
}
|
||
|
||
// 소수점 중복 입력 방지
|
||
const parts = filteredValue.split(/[+\-×÷]/)
|
||
if (parts[parts.length - 1].split('.').length > 2) {
|
||
// 한 숫자에 소수점이 2개 이상인 경우
|
||
return
|
||
}
|
||
|
||
setDisplayValue(filteredValue)
|
||
|
||
// 계산기 상태 업데이트
|
||
if (filteredValue !== displayValue) {
|
||
const calculator = calculatorRef.current
|
||
const hasOperation = /[+\-×÷]/.test(filteredValue)
|
||
|
||
if (hasOperation) {
|
||
const [operand1, operator, operand2] = filteredValue.split(/([+\-×÷])/)
|
||
calculator.previousOperand = operand1 || ''
|
||
calculator.operation = operator || ''
|
||
calculator.currentOperand = operand2 || ''
|
||
setHasOperation(true)
|
||
} else {
|
||
calculator.currentOperand = filteredValue
|
||
setHasOperation(false)
|
||
// 연산자가 없는 순수 숫자일 때만 부모 컴포넌트의 onChange 호출
|
||
onChange(filteredValue)
|
||
}
|
||
|
||
//onChange(filteredValue)
|
||
}
|
||
}
|
||
|
||
// 키패드 토글 함수
|
||
const toggleKeypad = (e) => {
|
||
if (e) {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
}
|
||
const newShowKeypad = !showKeypad
|
||
setShowKeypad(newShowKeypad)
|
||
|
||
// Show keypad 시에만 포커스 유지
|
||
if (newShowKeypad) {
|
||
setTimeout(() => {
|
||
inputRef.current?.focus()
|
||
}, 0)
|
||
}
|
||
}
|
||
|
||
// 키보드 이벤트 처리 수정
|
||
const handleKeyDown = (e) => {
|
||
if (readOnly) return
|
||
|
||
// Tab 키는 계산기 숨기고 기본 동작 허용
|
||
if (e.key === 'Tab') {
|
||
if (hasOperation) {
|
||
handleCompute(true) // 계산 수행
|
||
}
|
||
setShowKeypad(false)
|
||
return
|
||
}
|
||
|
||
// 모든 방향키는 기본 동작 허용
|
||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||
if (hasOperation) {
|
||
handleCompute(true) // 계산 수행
|
||
}
|
||
setShowKeypad(false)
|
||
return
|
||
}
|
||
|
||
// 계산기 허용 키들이 입력되면 키패드 표시 (엔터키 제외)
|
||
if (/^[0-9+\-×÷.=]$/.test(e.key) || e.key === 'Backspace' || e.key === 'Delete' || e.key === '*' || e.key === '/') {
|
||
setShowKeypad(true)
|
||
}
|
||
|
||
// 키패드가 숨겨진 상태에서 엔터키는 페이지로 전달
|
||
if (!showKeypad && e.key === 'Enter') {
|
||
return
|
||
}
|
||
|
||
// --- 여기서부터는 브라우저의 기본 입력을 막고 계산기 로직만 적용함 ---
|
||
if (e.key !== 'Process') { // 한글 입력 등 특수 상황 방지 (필요시)
|
||
// e.preventDefault() 호출 위치를 확인하세요.
|
||
}
|
||
|
||
|
||
e.preventDefault()
|
||
const calculator = calculatorRef.current
|
||
const { allowDecimal } = options
|
||
|
||
if (e.key === '.') {
|
||
// allowDecimal이 false이면 소수점 입력 무시
|
||
if (!allowDecimal) return
|
||
|
||
// 소수점 입력 처리
|
||
const currentValue = displayValue.toString()
|
||
const parts = currentValue.split(/[+\-×÷]/)
|
||
const lastPart = parts[parts.length - 1]
|
||
|
||
// 이미 소수점이 있으면 무시
|
||
if (!lastPart.includes('.')) {
|
||
handleNumber(e.key)
|
||
}
|
||
} else if (/^[0-9]$/.test(e.key)) {
|
||
handleNumber(e.key)
|
||
} else {
|
||
switch (e.key) {
|
||
case '+':
|
||
case '-':
|
||
case '*':
|
||
case '/':
|
||
const opMap = { '*': '×', '/': '÷' }
|
||
handleOperation(opMap[e.key] || e.key)
|
||
break
|
||
case 'Enter':
|
||
case '=':
|
||
if (showKeypad) {
|
||
// 키패드가 보이는 상태에서 엔터키: 계산 후 키패드만 숨김
|
||
handleCompute(true) // 엔터키로 호출됨을 표시
|
||
setShowKeypad(false)
|
||
}
|
||
// 키패드가 숨겨진 상태에서 엔터키: 페이지로 전달 (preventDefault 하지 않음)
|
||
break
|
||
case 'Backspace':
|
||
case 'Delete':
|
||
handleDelete()
|
||
break
|
||
|
||
case 'Escape':
|
||
handleClear()
|
||
setShowKeypad(false)
|
||
break
|
||
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div ref={containerRef} className="calculator-input-wrapper">
|
||
{label && (
|
||
<label htmlFor={id} className="calculator-label">
|
||
{label}
|
||
</label>
|
||
)}
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
id={id}
|
||
name={name}
|
||
value={displayValue}
|
||
readOnly={readOnly}
|
||
className={className}
|
||
onClick={() => !readOnly && setShowKeypad(true)}
|
||
onFocus={() => !readOnly && setShowKeypad(true)}
|
||
onChange={handleInputChange}
|
||
onKeyDown={handleKeyDown}
|
||
tabIndex={readOnly ? -1 : 0}
|
||
placeholder={placeholder}
|
||
autoComplete={'off'}
|
||
disabled={disabled}
|
||
maxLength={maxLength}
|
||
/>
|
||
|
||
{showKeypad && !readOnly && (
|
||
<div className="keypad-container">
|
||
<div className="keypad-grid">
|
||
<button
|
||
onClick={() => {
|
||
// const newValue = calculatorRef.current.clear()
|
||
// setDisplayValue(newValue || '0')
|
||
// onChange(newValue || '0')
|
||
handleClear()
|
||
setHasOperation(false)
|
||
}}
|
||
className="btn-clear"
|
||
>
|
||
AC
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
// const newValue = calculatorRef.current.deleteNumber()
|
||
// setDisplayValue(newValue || '0')
|
||
// onChange(newValue || '0')
|
||
//setHasOperation(!!calculatorRef.current.operation)
|
||
handleDelete()
|
||
}}
|
||
className="btn-delete"
|
||
>
|
||
DEL
|
||
</button>
|
||
<button onClick={() => handleOperation('÷')} className="btn-operator">
|
||
÷
|
||
</button>
|
||
|
||
<button onClick={() => handleOperation('×')} className="btn-operator">
|
||
×
|
||
</button>
|
||
<button onClick={() => handleOperation('-')} className="btn-operator">
|
||
-
|
||
</button>
|
||
<button onClick={() => handleOperation('+')} className="btn-operator">
|
||
+
|
||
</button>
|
||
|
||
{/* 숫자 버튼 */}
|
||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => (
|
||
<button key={num} onClick={() => handleNumber(num)} className="btn-number">
|
||
{num}
|
||
</button>
|
||
))}
|
||
{/* 0 버튼 */}
|
||
<button onClick={() => handleNumber('0')} className="btn-number btn-zero">
|
||
0
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
const newValue = calculatorRef.current.appendNumber('.')
|
||
onChange(newValue)
|
||
}}
|
||
className="btn-number"
|
||
>
|
||
.
|
||
</button>
|
||
{/* = 버튼 */}
|
||
<button onClick={() => handleCompute(false)} className="btn-equals">
|
||
=
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
},
|
||
)
|
||
|
||
CalculatorInput.displayName = 'CalculatorInput'
|