input 계산기 추가 및 전각=>반각

This commit is contained in:
ysCha 2025-08-29 15:48:44 +09:00
parent a55fca439c
commit 0c8374b43d
4 changed files with 799 additions and 6 deletions

View File

@ -0,0 +1,439 @@
import React, { useState, useRef, useEffect } from 'react'
import { createCalculator } from '@/util/calc-utils'
import '@/styles/calc.scss'
export const CalculatorInput = ({ value, onChange, label, options = {}, id, className = 'calculator-input', readOnly = false }) => {
const [showKeypad, setShowKeypad] = useState(false)
const calculatorRef = useRef(createCalculator(options))
const containerRef = useRef(null)
const inputRef = useRef(null) // input ref
//
useEffect(() => {
const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setShowKeypad(false)
if (/[+\-×÷]/.test(value)) {
const newValue = calculatorRef.current.clear()
onChange(newValue)
}
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [value, onChange])
// state
const [displayValue, setDisplayValue] = useState(value || '0')
const [hasOperation, setHasOperation] = useState(false)
//
const handleNumber = (num) => {
const calculator = calculatorRef.current
let newValue = ''
if (hasOperation) {
//
if (calculator.currentOperand === '0' || calculator.shouldResetDisplay) {
calculator.currentOperand = num.toString()
calculator.shouldResetDisplay = false
} else {
calculator.currentOperand = (calculator.currentOperand || '') + num
}
newValue = calculator.previousOperand + calculator.operation + calculator.currentOperand
setDisplayValue(newValue)
onChange(calculator.currentOperand)
} else {
//
if (value === '0' || calculator.shouldResetDisplay) {
calculator.currentOperand = num.toString()
calculator.shouldResetDisplay = false
} else {
calculator.currentOperand = (calculator.currentOperand || '') + num
}
newValue = calculator.currentOperand
setDisplayValue(newValue)
onChange(newValue)
}
//
requestAnimationFrame(() => {
if (inputRef.current) {
inputRef.current.focus()
const len = newValue.length
inputRef.current.setSelectionRange(len, len)
// Ensure focus is maintained
inputRef.current.focus()
}
})
}
//
const handleOperation = (operation) => {
const calculator = calculatorRef.current
// ( )
if (!calculator.currentOperand && calculator.previousOperand) {
calculator.operation = operation
const newValue = calculator.previousOperand + operation
setDisplayValue(newValue)
setHasOperation(true)
//
requestAnimationFrame(() => {
if (inputRef.current) {
inputRef.current.focus()
const len = newValue.length
inputRef.current.setSelectionRange(len, len)
inputRef.current.focus()
}
})
return
}
if (hasOperation) {
// ,
const result = calculator.compute()
if (result !== undefined) {
calculator.previousOperand = result.toString()
calculator.operation = operation
calculator.currentOperand = ''
const newValue = result.toString() + operation
setDisplayValue(newValue)
setHasOperation(true)
onChange(result.toString())
//
requestAnimationFrame(() => {
if (inputRef.current) {
inputRef.current.focus()
const len = newValue.length
inputRef.current.setSelectionRange(len, len)
inputRef.current.focus()
}
})
}
} else {
//
calculator.previousOperand = displayValue
calculator.operation = operation
calculator.currentOperand = ''
const newValue = displayValue + operation
setDisplayValue(newValue)
setHasOperation(true)
onChange(displayValue)
//
requestAnimationFrame(() => {
if (inputRef.current) {
inputRef.current.focus()
const len = newValue.length
inputRef.current.setSelectionRange(len, len)
inputRef.current.focus()
}
})
}
}
// 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)
// Ensure focus is maintained
inputRef.current.focus()
}
})
}
//
const handleCompute = () => {
if (!hasOperation) return
const calculator = calculatorRef.current
// 0
if (!calculator.currentOperand) {
calculator.currentOperand = '0'
}
//
const result = calculator.compute()
//
if (result === undefined || result === null) {
console.error('계산 결과가 유효하지 않습니다.')
return
}
//
const resultStr = result.toString()
setDisplayValue(resultStr)
setHasOperation(false)
onChange(resultStr)
// ( )
calculator.clear()
calculator.previousOperand = resultStr
//
requestAnimationFrame(() => {
if (inputRef.current) {
inputRef.current.focus()
const len = resultStr.length
inputRef.current.setSelectionRange(len, len)
// Ensure focus is maintained
inputRef.current.focus()
}
})
}
// 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)
// Ensure focus is maintained
inputRef.current.focus()
}
})
}
// 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(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') {
setShowKeypad(false)
return
}
//
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
setShowKeypad(true)
return
}
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 '=':
handleCompute()
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}
value={displayValue}
readOnly={readOnly}
className={className}
onClick={() => !readOnly && setShowKeypad(true)}
onFocus={() => !readOnly && setShowKeypad(true)}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
tabIndex={readOnly ? -1 : 0}
/>
{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>
{/* 숫자 버튼 */}
{[7, 8, 9, 4, 5, 6, 1, 2, 3].map((num) => (
<button key={num} onClick={() => handleNumber(num)} className="btn-number">
{num}
</button>
))}
<button onClick={() => handleOperation('×')} className="btn-operator">
×
</button>
<button onClick={() => handleOperation('-')} className="btn-operator">
-
</button>
<button onClick={() => handleOperation('+')} className="btn-operator">
+
</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} className="btn-equals">
=
</button>
</div>
</div>
)}
</div>
)
}

View File

@ -21,6 +21,7 @@ import { useRoofFn } from '@/hooks/common/useRoofFn'
import { usePlan } from '@/hooks/usePlan'
import { normalizeDecimal, normalizeDigits } from '@/util/input-utils'
import { logger } from '@/util/logger'
import { CalculatorInput } from '@/components/common/input/CalcInput'
/**
* 지붕 레이아웃
@ -326,11 +327,11 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
</div>
</span>
<div className="input-grid mr5">
<input
{/* <input
type="text"
className="input-origin block"
readOnly={currentRoof?.roofAngleSet !== item.value}
value={index === 0 ? (currentRoof?.pitch || '0') : (currentRoof?.angle || '0')}
value={index === 0 ? currentRoof?.pitch || '0' : currentRoof?.angle || '0'}
onChange={(e) => {
const v = normalizeDecimal(e.target.value)
e.target.value = v
@ -342,6 +343,34 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
setCurrentRoof({ ...currentRoof, pitch: num === '' ? '' : getChonByDegree(num), angle: num === '' ? '' : num })
}
}}
/> */}
<CalculatorInput
id=""
label=""
className="input-origin block"
readOnly={currentRoof?.roofAngleSet !== item.value}
value={index === 0 ? currentRoof?.pitch || '0' : currentRoof?.angle || '0'}
onChange={(value) => {
if (index === 0) {
const num = value === '' ? '' : Number(value)
setCurrentRoof({
...currentRoof,
pitch: num === '' ? '' : num,
angle: num === '' ? '' : getDegreeByChon(num),
})
} else {
const num = value === '' ? '' : Number(value)
setCurrentRoof({
...currentRoof,
pitch: num === '' ? '' : getChonByDegree(num),
angle: num === '' ? '' : num,
})
}
}}
options={{
allowNegative: false,
allowDecimal: (index !== 0),
}}
/>
</div>
<span className="thin">{index === 0 ? '寸' : '度'}</span>
@ -420,10 +449,8 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
<div className="select-wrap" style={{ width: '160px' }}>
<QSelectBox
options={raftCodes}
title={
raftCodes?.find((r) => r.clCode === ( currentRoof.raft?? currentRoof?.raftBaseCd))?.clCodeNm
}
value={currentRoof?.raft??currentRoof?.raftBaseCd}
title={raftCodes?.find((r) => r.clCode === (currentRoof.raft ?? currentRoof?.raftBaseCd))?.clCodeNm}
value={currentRoof?.raft ?? currentRoof?.raftBaseCd}
onChange={(e) => handleRafterChange(e.clCode)}
sourceKey="clCode"
targetKey={currentRoof?.raft ? 'raft' : 'raftBaseCd'}

155
src/styles/calc.scss Normal file
View File

@ -0,0 +1,155 @@
// Variables
$colors: (
'dark-700': #1f2937,
'dark-600': #334155,
'dark-500': #4b5563,
'dark-400': #6b7280,
'dark-300': #374151,
'primary': #10b981,
'primary-dark': #059669,
'danger': #ef4444,
'danger-dark': #dc2626,
'warning': #f59e0b,
'warning-dark': #d97706,
'border': #475569
);
// Mixins
@mixin button-styles {
padding: 0.125rem;
border-radius: 0.5rem;
font-weight: bold;
font-size: 0.625rem;
color: white;
border: none;
cursor: pointer;
transition: all 0.1s ease-in-out;
&:active {
transform: scale(0.95);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
}
// Calculator Input Wrapper
.calculator-input-wrapper {
position: relative;
width: 100%;
display: inline-block;
// Input Field
.calculator-input {
width: 100%;
padding: 0.5rem 1rem;
background-color: map-get($colors, 'dark-600');
border: 1px solid map-get($colors, 'border');
color: white;
font-weight: bold;
border-radius: 0.5rem;
text-align: right;
cursor: pointer;
font-size: 0.625rem;
box-sizing: border-box;
&:focus {
outline: none;
border-color: map-get($colors, 'primary');
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
}
}
// Keypad Container
.keypad-container {
position: absolute;
top: calc(100% + 2px);
left: 0;
right: 0;
width: 120px;
background-color: map-get($colors, 'dark-700');
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
padding: 0.5rem;
z-index: 1000;
animation: fadeIn 0.15s ease-out;
border: 1px solid map-get($colors, 'border');
box-sizing: border-box;
// Keypad Grid
.keypad-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.125rem;
// Button Base
button {
@include button-styles;
}
// Button Types
.btn-number {
background-color: map-get($colors, 'dark-500');
&:hover {
background-color: map-get($colors, 'dark-400');
}
}
.btn-operator {
background-color: map-get($colors, 'warning');
&:hover {
background-color: map-get($colors, 'warning-dark');
}
}
.btn-clear {
background-color: map-get($colors, 'danger');
grid-column: span 2;
&:hover {
background-color: map-get($colors, 'danger-dark');
}
}
.btn-delete {
background-color: map-get($colors, 'dark-500');
&:hover {
background-color: map-get($colors, 'dark-300');
}
}
.btn-equals {
background-color: map-get($colors, 'primary');
&:hover {
background-color: map-get($colors, 'primary-dark');
}
}
.btn-zero {
grid-column: span 2;
}
}
}
}
// Animations
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
// Responsive Design
@media (max-width: 640px) {
.keypad-grid button {
padding: 0.5rem;
font-size: 0.875rem;
}
}

172
src/util/calc-utils.js Normal file
View File

@ -0,0 +1,172 @@
export const createCalculator = (options = {}) => {
const state = {
currentOperand: '',
previousOperand: '',
operation: undefined,
shouldResetDisplay: false,
allowNegative: options.allowNegative ?? true,
allowDecimal: options.allowDecimal ?? true,
allowZero: options.allowZero ?? true,
decimalPlaces: options.decimalPlaces ?? null,
}
// Expose state for debugging and direct access
const getState = () => ({ ...state })
const clear = () => {
state.currentOperand = ''
state.previousOperand = ''
state.operation = undefined
state.shouldResetDisplay = false
return state.currentOperand
}
const deleteNumber = () => {
if (state.currentOperand.length <= 1) {
state.currentOperand = '0'
} else {
state.currentOperand = state.currentOperand.toString().slice(0, -1)
}
return state.currentOperand
}
const appendNumber = (number) => {
if (number === '.' && !state.allowDecimal) return state.currentOperand
if (number === '.' && state.currentOperand.includes('.')) return state.currentOperand
if (state.shouldResetDisplay) {
state.currentOperand = number.toString()
state.shouldResetDisplay = false
} else {
if (state.currentOperand === '0' && number !== '.') {
state.currentOperand = number.toString()
} else {
state.currentOperand = state.currentOperand.toString() + number.toString()
}
}
return state.currentOperand
}
const chooseOperation = (operation) => {
if (operation === '-' && state.currentOperand === '0' && state.previousOperand === '' && state.allowNegative) {
state.currentOperand = '-'
return state.currentOperand
}
if (state.currentOperand === '' || state.currentOperand === '-') return state.currentOperand
// If there's a previous operation, compute it first
if (state.previousOperand !== '') {
compute()
}
state.operation = operation
state.previousOperand = state.currentOperand
state.currentOperand = ''
return state.previousOperand + state.operation
}
const compute = () => {
// If there's no operation, return the current value
if (!state.operation) return parseFloat(state.currentOperand || '0')
// If there's no current operand but we have a previous one, use previous as current
if (state.currentOperand === '' && state.previousOperand !== '') {
state.currentOperand = state.previousOperand
}
const prev = parseFloat(state.previousOperand)
const current = parseFloat(state.currentOperand)
if (isNaN(prev) || isNaN(current)) return 0
let result
switch (state.operation) {
case '+':
result = prev + current
break
case '-':
result = prev - current
break
case '×':
result = prev * current
break
case '÷':
if (current === 0) {
state.currentOperand = 'Error'
return 0
}
result = prev / current
break
default:
return parseFloat(state.currentOperand || '0')
}
// Apply formatting and constraints
if (state.decimalPlaces !== null) {
result = Number(result.toFixed(state.decimalPlaces))
}
if (!state.allowDecimal) {
result = Math.round(result)
}
if (!state.allowNegative && result < 0) {
result = 0
}
if (!state.allowZero && result === 0) {
result = 1
}
// Update state
state.currentOperand = result.toString()
state.previousOperand = ''
state.operation = undefined
state.shouldResetDisplay = true
return result
}
// Getter methods for the calculator state
const getCurrentOperand = () => state.currentOperand
const getPreviousOperand = () => state.previousOperand
const getOperation = () => state.operation
const getDisplayValue = () => {
if (state.operation && state.previousOperand) {
return `${state.previousOperand} ${state.operation} ${state.currentOperand || ''}`.trim()
}
return state.currentOperand
}
return {
// Core calculator methods
clear,
delete: deleteNumber, // Alias for deleteNumber for compatibility
deleteNumber,
appendNumber,
chooseOperation,
compute,
// State getters
getDisplayValue,
getCurrentOperand,
getPreviousOperand,
getOperation,
// Direct state access (for debugging)
getState,
// Direct property access (for compatibility with CalcInput.jsx)
get currentOperand() { return state.currentOperand },
get previousOperand() { return state.previousOperand },
get operation() { return state.operation },
get shouldResetDisplay() { return state.shouldResetDisplay },
// Setter for direct property access (if needed)
set currentOperand(value) { state.currentOperand = value },
set previousOperand(value) { state.previousOperand = value },
set operation(value) { state.operation = value },
set shouldResetDisplay(value) { state.shouldResetDisplay = value }
}
}