diff --git a/src/components/common/input/CalcInput.jsx b/src/components/common/input/CalcInput.jsx new file mode 100644 index 00000000..b85a9393 --- /dev/null +++ b/src/components/common/input/CalcInput.jsx @@ -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 ( +
+ {label && ( + + )} + !readOnly && setShowKeypad(true)} + onFocus={() => !readOnly && setShowKeypad(true)} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + tabIndex={readOnly ? -1 : 0} + /> + + {showKeypad && !readOnly && ( +
+
+ + + + + {/* 숫자 버튼 */} + {[7, 8, 9, 4, 5, 6, 1, 2, 3].map((num) => ( + + ))} + + + + + + {/* 0 버튼 */} + + + {/* = 버튼 */} + +
+
+ )} +
+ ) +} diff --git a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx index d6bab594..2d5d9c41 100644 --- a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx +++ b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx @@ -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
- { 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 }) } }} + /> */} + { + 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), + }} />
{index === 0 ? '寸' : '度'} @@ -420,10 +449,8 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
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'} diff --git a/src/styles/calc.scss b/src/styles/calc.scss new file mode 100644 index 00000000..f30bc691 --- /dev/null +++ b/src/styles/calc.scss @@ -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; + } +} \ No newline at end of file diff --git a/src/util/calc-utils.js b/src/util/calc-utils.js new file mode 100644 index 00000000..49e76721 --- /dev/null +++ b/src/util/calc-utils.js @@ -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 } + } +}