From 43756660842dbbd0d597959cdfa76358658314f3 Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 22 Aug 2025 18:28:48 +0900 Subject: [PATCH] =?UTF-8?q?[1252]=20=EC=9D=BC=EB=B3=B8=EC=9D=98=20?= =?UTF-8?q?=EC=A0=84=EA=B0=81=20=EC=88=AB=EC=9E=90,=20=EB=B0=98=EA=B0=81?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/input-utils.js | 251 +++++++++++++++++++++++++--------------- 1 file changed, 156 insertions(+), 95 deletions(-) diff --git a/src/util/input-utils.js b/src/util/input-utils.js index 65bb4dd4..c772377b 100644 --- a/src/util/input-utils.js +++ b/src/util/input-utils.js @@ -1,122 +1,183 @@ -// 숫자만 입력 가능한 input onChange 함수 -export const onlyNumberInputChange = (e, callback) => { - let value = e.target.value - value = value.replace(/[^-0-9]/g, '') - callback(value, e) +// 간단한 IME 감지 함수 +function isIMEComposing(e) { + // compositionstart ~ compositionend 사이의 입력은 IME 조합 중 + return e.nativeEvent?.isComposing || e.isComposing || false } -//소수점 둘째자리 숫자만 입력가능 -export const onlyNumberWithDotInputChange = (e, callback) => { - const val = e.target.value - - const pattern = /^-?(\d{1,4}([.]\d{0,2})?)?$/ - if (!pattern.test(val)) { - // prev에서 마지막 자리 제거 - callback(val.slice(0, val.length - 1), e) +// 숫자만 입력 가능한 input onChange 함수 (음수 포함) +export const onlyNumberInputChange = (e, callback) => { + // IME 조합 중이면 그대로 전달 + if (isIMEComposing(e)) { + callback(e.target.value, e) return } - callback(val, e) + let value = e.target.value + value = value.replace(/[^-0-9]/g, '') + // 음수 기호는 맨 앞에만 허용 + if (value.indexOf('-') > 0) { + value = value.replace(/-/g, '') + } + // 연속된 음수 기호 제거 + value = value.replace(/^-+/, '-') + callback(value, e) } +// 소수점 둘째자리 숫자만 입력가능 (음수 포함, 개선된 로직) +export const onlyNumberWithDotInputChange = (e, callback) => { + // IME 조합 중이면 그대로 전달 + if (isIMEComposing(e)) { + callback(e.target.value, e) + return + } + + const val = e.target.value + + // 음수를 포함한 소수점 패턴 (최대 4자리 정수, 2자리 소수) + const pattern = /^-?(\d{0,4}([.]\d{0,2})?)?$/ + + if (!pattern.test(val)) { + // 패턴에 맞지 않으면 마지막 입력 문자 제거 + const correctedValue = val.slice(0, val.length - 1) + callback(correctedValue, e) + return + } + + // 음수 기호가 중간에 있으면 제거 + let correctedValue = val + if (val.indexOf('-') > 0) { + correctedValue = val.replace(/-/g, '') + } + + callback(correctedValue, e) +} // ============================= // Number normalization utilities // ============================= -// 마지막으로 유효했던 값 추적 -let lastValidDigits = ''; -let lastValidDecimal = ''; -/** - * 숫자만 포함된 문자열로 정규화 (0-9) - * - 전각 숫자 -> 반각 - * - 숫자 외 제거 - * - 결과가 유효하면 길이/증가폭과 무관하게 허용 - */ -export function normalizeDigits(value) { - if (value == null || value === '') { - lastValidDigits = ''; - return ''; - } +// 1) Normalize any string to NFKC and keep only ASCII digits 0-9. +export function normalizeDigits(value, allowNegative = false) { + // 1. 전각 숫자를 반각으로 변환 + const halfWidth = fullToHalf(String(value ?? '')); + // 2. NFKC 정규화 + const normalized = halfWidth.normalize('NFKC'); - const converted = String(value).replace(/[0-9]/g, s => - String.fromCharCode(s.charCodeAt(0) - 0xFEE0) - ); - const normalized = converted.replace(/\D/g, ''); - - if (normalized === '') { - lastValidDigits = ''; - return ''; - } - - if (/^\d+$/.test(normalized)) { - lastValidDigits = normalized; - return normalized; - } - - return lastValidDigits || ''; -} - -/** - * 소수점이 포함된 숫자 문자열로 정규화 - * - 전각 숫자/점 -> 반각 - * - 숫자/점 외 제거 - * - 점은 첫 번째만 허용 - */ -export function normalizeDecimal(value) { - if (value == null || value === '') { - lastValidDecimal = ''; - return ''; - } - - let converted = String(value).replace(/[0-9.]/g, s => - s === '.' ? '.' : String.fromCharCode(s.charCodeAt(0) - 0xFEE0) - ); - converted = converted.replace(/[^0-9.]/g, ''); - - const firstDot = converted.indexOf('.'); - let normalized; - - if (firstDot !== -1) { - const integerPart = converted.slice(0, firstDot).replace(/\D/g, ''); - const fractionPart = converted.slice(firstDot + 1).replace(/\D/g, ''); - normalized = integerPart + '.' + fractionPart; + if (allowNegative) { + // 음수 허용 시 + let result = normalized.replace(/[^-0-9]/g, ''); + // 음수 기호는 맨 앞에만 허용 + if (result.indexOf('-') > 0) { + result = result.replace(/-/g, ''); + } + // 연속된 음수 기호 제거 + result = result.replace(/^-+/, '-'); + return result; } else { - normalized = converted.replace(/\D/g, ''); + // 양수만 허용 + return normalized.replace(/[^0-9]/g, ''); } - - if (normalized === '') { - lastValidDecimal = ''; - return ''; - } - - if (/^\d+(\.\d*)?$/.test(normalized) || /^\d+$/.test(normalized)) { - lastValidDecimal = normalized; - return normalized; - } - - return lastValidDecimal || ''; } +export function normalizeDecimal(value, allowNegative = false) { + // 1. 전각 숫자와 기호를 반각으로 변환 + const halfWidth = fullToHalf(String(value ?? '')); + // 2. NFKC 정규화 + const normalized = halfWidth.normalize('NFKC'); + + let result; + + if (allowNegative) { + // 음수와 소수점 허용 + result = normalized.replace(/[^-0-9.]/g, ''); + // 음수 기호는 맨 앞에만 허용 + if (result.indexOf('-') > 0) { + result = result.replace(/-/g, ''); + } + // 연속된 음수 기호 제거 + result = result.replace(/^-+/, '-'); + } else { + // 양수만 허용 (소수점 포함) + result = normalized.replace(/[^0-9.]/g, ''); + } + + // 소수점은 하나만 허용 + const [head, ...rest] = result.split('.'); + return rest.length ? `${head}.${rest.join('').replace(/\./g, '')}` : head; +} // 2-1) Limit fractional digits for decimal numbers. Default to 2 digits. -export function normalizeDecimalLimit(value, maxFractionDigits = 2) { - const s = normalizeDecimal(value) +export function normalizeDecimalLimit(value, maxFractionDigits = 2, maxIntegerDigits = null, allowNegative = false) { + const s = normalizeDecimal(value, allowNegative) if (!s) return s - const [intPart, fracPart] = s.split('.') - if (fracPart === undefined) return intPart - return `${intPart}.${fracPart.slice(0, Math.max(0, maxFractionDigits))}` + + const isNegative = s.startsWith('-') + const absoluteValue = isNegative ? s.slice(1) : s + const [intPart, fracPart] = absoluteValue.split('.') + + // 정수 부분 자릿수 제한 + let limitedIntPart = intPart + if (maxIntegerDigits && intPart.length > maxIntegerDigits) { + limitedIntPart = intPart.slice(0, maxIntegerDigits) + } + + // 소수 부분 자릿수 제한 + let result = limitedIntPart + if (fracPart !== undefined) { + const limitedFracPart = fracPart.slice(0, Math.max(0, maxFractionDigits)) + if (limitedFracPart.length > 0) { + result = `${limitedIntPart}.${limitedFracPart}` + } + } + + return isNegative ? `-${result}` : result } -// 3) DOM input event helpers (optional): mutate target.value and return normalized value -export function sanitizeIntegerInputEvent(e) { - const v = normalizeDigits(e?.target?.value) - if (e?.target) e.target.value = v +// 3) DOM input event helpers: mutate target.value and return normalized value +export function sanitizeIntegerInputEvent(e, allowNegative = false) { + if (!e?.target) return '' + + // IME 조합 중이면 원본 값 반환 + if (isIMEComposing(e)) { + return e.target.value + } + + const v = normalizeDigits(e.target.value, allowNegative) + e.target.value = v return v } -export function sanitizeDecimalInputEvent(e) { - const v = normalizeDecimal(e?.target?.value) - if (e?.target) e.target.value = v +export function sanitizeDecimalInputEvent(e, allowNegative = false) { + if (!e?.target) return '' + + // IME 조합 중이면 원본 값 반환 + if (isIMEComposing(e)) { + return e.target.value + } + + const v = normalizeDecimal(e.target.value, allowNegative) + e.target.value = v return v +} + +export function sanitizeDecimalLimitInputEvent(e, maxFractionDigits = 2, maxIntegerDigits = null, allowNegative = false) { + if (!e?.target) return '' + + // IME 조합 중이면 원본 값 반환 + if (isIMEComposing(e)) { + return e.target.value + } + + const v = normalizeDecimalLimit(e.target.value, maxFractionDigits, maxIntegerDigits, allowNegative) + e.target.value = v + return v +} + +export function fullToHalf(str) { + if (!str) return ''; + + // Convert full-width numbers (0-9) to half-width (0-9) + return str.replace(/[0-9]/g, function(s) { + return String.fromCharCode(s.charCodeAt(0) - 0xFEE0); + }); } \ No newline at end of file