From f23d93121737a851ec28814e67ad9d96c154d3c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sun, 21 Jun 2026 18:12:15 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=EB=85=B8=ED=8A=B8=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=20px=20=ED=95=84=EB=93=9C=EB=A5=BC=20f64=EB=A1=9C=20w?= =?UTF-8?q?idening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit note_width, note_border_radius, note_glow_size를 u32에서 f64로 변경해 소수 입력을 받을 수 있도록 모델 타입 확장. 기존 정수 저장값은 serde가 그대로 역직렬화하므로 별도 변환 불필요. 레거시 borderRadius 마이그레이션은 f64 캐스트 추가 --- src-tauri/src/models/mod.rs | 10 +++++----- src-tauri/src/state/migration.rs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 231a68d9..a1cdf408 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -206,10 +206,10 @@ pub struct KeyPosition { pub note_color: NoteColor, pub note_opacity: u32, #[serde(default)] - pub note_border_radius: Option, + pub note_border_radius: Option, /// 노트 넓이(px). None이면 키 width를 사용(자동). #[serde(default)] - pub note_width: Option, + pub note_width: Option, /// 노트 정렬 (left/center/right). 기본값 center. #[serde(default)] pub note_alignment: NoteAlignment, @@ -218,7 +218,7 @@ pub struct KeyPosition { #[serde(default = "default_note_glow_enabled")] pub note_glow_enabled: bool, #[serde(default = "default_note_glow_size")] - pub note_glow_size: u32, + pub note_glow_size: f64, #[serde(default = "default_note_glow_opacity")] pub note_glow_opacity: u32, #[serde(default)] @@ -760,8 +760,8 @@ fn default_note_effect_enabled() -> bool { fn default_note_glow_enabled() -> bool { false } -fn default_note_glow_size() -> u32 { - 20 +fn default_note_glow_size() -> f64 { + 20.0 } fn default_note_glow_opacity() -> u32 { 70 diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index f846160d..8f0d981d 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -329,7 +329,7 @@ pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { if let Some(legacy_border_radius) = data.note_settings.border_radius.take() { for positions in data.key_positions.values_mut() { for pos in positions.iter_mut() { - pos.note_border_radius = Some(legacy_border_radius); + pos.note_border_radius = Some(legacy_border_radius as f64); } } } From ca85a6aba4923cf4e0cff2bc13488f0f00c30b4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sun, 21 Jun 2026 18:38:23 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EB=85=B8=ED=8A=B8=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=C2=B7=ED=85=8C=EB=91=90=EB=A6=AC=20=EB=91=90=EA=BB=98?= =?UTF-8?q?=20=EB=93=B1=20=EC=88=98=EC=B9=98=20=EA=B0=92=20=EC=86=8C?= =?UTF-8?q?=EC=88=98=20=EC=9E=85=EB=A0=A5=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 키/노트 크기, 테두리 두께·라운딩, 글로우 크기, 오프셋, 폰트 크기 등 시각적 px 값에 0.1 단위 소수 입력을 허용. 카운트·투명도 등은 정수 유지 - OptionalNumberInput에 allowDecimal/decimalScale 추가 (NumberInput과 동일한 소수 sanitize·precision 처리, numpad Decimal 키, inputMode decimal) - Note/Style/Batch 탭의 px 입력에 allowDecimal 적용 - zod 스키마에서 noteWidth/noteBorderRadius/noteGlowSize의 정수 제약 해제 - noteWidth 렌더 시 Math.round 제거로 소수 폭 반영 - 구형 키 설정 모달의 글로우 크기 입력도 소수 지원 - 빈 값만 unset, 부호·소수점 중간 입력은 commit 보류 zod·serde 회귀 테스트 추가 --- src-tauri/src/models/mod.rs | 48 +++++++++++ .../Grid/PropertiesPanel/PropertyInputs.tsx | 86 +++++++++++++++---- .../batch/BatchNoteTabContent.tsx | 20 +++-- .../batch/BatchStyleTabContent.tsx | 6 ++ .../PropertiesPanel/single/NoteTabContent.tsx | 14 ++- .../single/StyleTabContent.tsx | 6 ++ .../main/Grid/PropertiesPanel/types.ts | 2 + .../Modal/content/settings/NoteTabContent.tsx | 22 ++++- .../hooks/shared/useLayoutComputation.ts | 4 +- src/types/key/keys.test.ts | 26 ++++++ src/types/key/keys.ts | 5 +- 11 files changed, 204 insertions(+), 35 deletions(-) create mode 100644 src/types/key/keys.test.ts diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index a1cdf408..4f1322f3 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1812,3 +1812,51 @@ pub struct SettingsPatch { #[serde(skip_serializing_if = "Option::is_none")] pub obs_mode_enabled: Option, } + +#[cfg(test)] +mod tests { + use super::KeyPosition; + + // 필수 필드만 채운 최소 KeyPosition JSON. 시각 px 필드는 호출부에서 주입 + fn key_position_json(visual_px: &str) -> String { + format!( + r##"{{ + "dx": 0.0, "dy": 0.0, "width": 60.0, "height": 60.0, + "count": 0, "noteColor": "#FFFFFF", "noteOpacity": 80, + {visual_px} + }}"## + ) + } + + // 기존 정수 저장값이 f64 필드로 그대로 역직렬화되는지 (하위 호환) + #[test] + fn visual_px_fields_accept_integer_json() { + let json = + key_position_json(r#""noteWidth": 100, "noteBorderRadius": 8, "noteGlowSize": 20"#); + let pos: KeyPosition = serde_json::from_str(&json).unwrap(); + assert_eq!(pos.note_width, Some(100.0)); + assert_eq!(pos.note_border_radius, Some(8.0)); + assert_eq!(pos.note_glow_size, 20.0); + } + + // 소수 저장값이 정상 역직렬화되는지 + #[test] + fn visual_px_fields_accept_decimal_json() { + let json = key_position_json( + r#""noteWidth": 100.5, "noteBorderRadius": 8.5, "noteGlowSize": 20.5"#, + ); + let pos: KeyPosition = serde_json::from_str(&json).unwrap(); + assert_eq!(pos.note_width, Some(100.5)); + assert_eq!(pos.note_border_radius, Some(8.5)); + assert_eq!(pos.note_glow_size, 20.5); + } + + // note_glow_size 미지정 시 기본값(20.0) 적용 + #[test] + fn note_glow_size_defaults_to_20() { + let json = key_position_json(r#""noteWidth": null"#); + let pos: KeyPosition = serde_json::from_str(&json).unwrap(); + assert_eq!(pos.note_glow_size, 20.0); + assert_eq!(pos.note_width, None); + } +} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx index dabf6467..e6ce598e 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx @@ -300,16 +300,61 @@ export const OptionalNumberInput: React.FC = ({ width = '54px', placeholder, allowNegative = false, + allowDecimal = false, + decimalScale = 1, isMixed = false, mixedPlaceholder = 'Mixed', }) => { const hasSuffix = !!suffix; + const resolvedDecimalScale = allowDecimal + ? Math.max(0, Math.floor(decimalScale)) + : 0; + const supportsDecimal = resolvedDecimalScale > 0; + const inputMode = supportsDecimal ? 'decimal' : 'numeric'; + + const normalizePrecision = (num: number): number => { + if (!supportsDecimal) return num; + return Number(num.toFixed(resolvedDecimalScale)); + }; + + // 숫자/부호/소수점만 남기고, 부호는 맨 앞에 하나, 소수점도 하나만 유지 + const sanitizeInput = (raw: string): string => { + const pattern = supportsDecimal + ? allowNegative + ? /[^0-9.-]/g + : /[^0-9.]/g + : allowNegative + ? /[^0-9-]/g + : /[^0-9]/g; + let sanitized = raw.replace(pattern, ''); + + if (allowNegative) { + const isNegative = sanitized.startsWith('-'); + sanitized = sanitized.replace(/-/g, ''); + if (isNegative) sanitized = `-${sanitized}`; + } + + if (!supportsDecimal) return sanitized; + + const sign = sanitized.startsWith('-') ? '-' : ''; + const unsigned = sign ? sanitized.slice(1) : sanitized; + const dotIndex = unsigned.indexOf('.'); + if (dotIndex === -1) return `${sign}${unsigned}`; + + const integerPart = unsigned.slice(0, dotIndex); + const fractionalPart = unsigned + .slice(dotIndex + 1) + .replace(/\./g, '') + .slice(0, resolvedDecimalScale); + return `${sign}${integerPart}.${fractionalPart}`; + }; const getDisplayValue = (val: number, focused: boolean): string => { + const normalized = normalizePrecision(val); if (hasSuffix && !focused) { - return `${val}${suffix}`; + return `${normalized}${suffix}`; } - return String(val); + return String(normalized); }; const [localValue, setLocalValue] = useState(() => { @@ -349,31 +394,30 @@ export const OptionalNumberInput: React.FC = ({ if (e.ctrlKey || e.metaKey) return; if (/^[0-9]$/.test(e.key)) return; if (allowNegative && e.key === '-') return; + if (supportsDecimal && (e.key === '.' || e.key === 'Decimal')) return; e.preventDefault(); }; - const numericPattern = allowNegative ? /[^0-9-]/g : /[^0-9]/g; - const handleChange = (e: React.ChangeEvent) => { - const raw = e.target.value.replace(numericPattern, ''); - // 마이너스는 맨 앞에만 허용 - const newValue = allowNegative - ? (raw.startsWith('-') ? '-' : '') + raw.replace(/-/g, '') - : raw; + const newValue = sanitizeInput(e.target.value); setLocalValue(newValue); setHasUserInput(true); - if (newValue === '' || newValue === '-') { + // 빈 값만 unset, 부호·소수점만 남은 중간 상태는 commit하지 않고 입력 유지 + if (newValue === '') { onChange(undefined); return; } + if (newValue === '-' || newValue === '.' || newValue === '-.') { + return; + } const numValue = Number(newValue); if (!Number.isFinite(numValue)) return; const clamped = Math.min(Math.max(numValue, min), max); - onChange(clamped); + onChange(normalizePrecision(clamped)); }; const handleFocus = () => { @@ -388,9 +432,7 @@ export const OptionalNumberInput: React.FC = ({ const handleBlur = () => { setIsFocused(false); - const cleaned = allowNegative - ? localValue.replace(/[^0-9-]/g, '') - : localValue.replace(/[^0-9]/g, ''); + const cleaned = sanitizeInput(localValue); // Mixed 상태에서 사용자 입력이 없었으면 Mixed 유지 if (isMixed && !hasUserInput) { @@ -400,7 +442,13 @@ export const OptionalNumberInput: React.FC = ({ return; } - if (cleaned === '' || cleaned === '-' || isNaN(Number(cleaned))) { + if ( + cleaned === '' || + cleaned === '-' || + cleaned === '.' || + cleaned === '-.' || + isNaN(Number(cleaned)) + ) { setLocalValue(''); onChange(undefined); setHasUserInput(false); @@ -409,7 +457,7 @@ export const OptionalNumberInput: React.FC = ({ } const numValue = Number(cleaned); - const clamped = Math.min(Math.max(numValue, min), max); + const clamped = normalizePrecision(Math.min(Math.max(numValue, min), max)); setLocalValue(getDisplayValue(clamped, false)); onChange(clamped); setHasUserInput(false); @@ -443,7 +491,7 @@ export const OptionalNumberInput: React.FC = ({ )} = ({ return ( = ({ return ( = ({ {/* 오프셋 */} pos.noteOffsetX, 0).value || undefined - } + value={getMixedValue((pos) => pos.noteOffsetX, 0).value || undefined} onChange={(value) => handleBatchStyleChangeComplete('noteOffsetX', value) } prefix="X" allowNegative + allowDecimal + decimalScale={1} min={NOTE_SETTINGS_CONSTRAINTS.noteOffsetX.min} max={NOTE_SETTINGS_CONSTRAINTS.noteOffsetX.max} placeholder="0" isMixed={getMixedValue((pos) => pos.noteOffsetX, 0).isMixed} /> pos.noteOffsetY, 0).value || undefined - } + value={getMixedValue((pos) => pos.noteOffsetY, 0).value || undefined} onChange={(value) => handleBatchStyleChangeComplete('noteOffsetY', value) } prefix="Y" allowNegative + allowDecimal + decimalScale={1} min={NOTE_SETTINGS_CONSTRAINTS.noteOffsetY.min} max={NOTE_SETTINGS_CONSTRAINTS.noteOffsetY.max} placeholder="0" @@ -154,6 +154,8 @@ const BatchNoteTabContent: React.FC = ({ } suffix="px" min={1} + allowDecimal + decimalScale={1} placeholder="Auto" isMixed={noteWidthMixed.isMixed} /> @@ -332,6 +334,8 @@ const BatchNoteTabContent: React.FC = ({ suffix="px" min={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.min} max={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.max} + allowDecimal + decimalScale={1} isMixed={ getMixedValue( (pos) => pos.noteBorderWidth, @@ -356,6 +360,8 @@ const BatchNoteTabContent: React.FC = ({ suffix="px" min={NOTE_SETTINGS_CONSTRAINTS.borderRadius.min} max={NOTE_SETTINGS_CONSTRAINTS.borderRadius.max} + allowDecimal + decimalScale={1} isMixed={ getMixedValue( (pos) => pos.noteBorderRadius, @@ -410,6 +416,8 @@ const BatchNoteTabContent: React.FC = ({ suffix="px" min={0} max={50} + allowDecimal + decimalScale={1} isMixed={getMixedValue((pos) => pos.noteGlowSize, 20).isMixed} /> diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx index a7621253..dad73426 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx @@ -630,6 +630,8 @@ const BatchStyleTabContent: React.FC = ({ suffix="px" min={0} max={20} + allowDecimal + decimalScale={1} /> @@ -646,6 +648,8 @@ const BatchStyleTabContent: React.FC = ({ suffix="px" min={0} max={100} + allowDecimal + decimalScale={1} /> @@ -722,6 +726,8 @@ const BatchStyleTabContent: React.FC = ({ suffix="px" min={8} max={72} + allowDecimal + decimalScale={1} /> diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx index 161e50e1..69134671 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx @@ -462,6 +462,8 @@ const NoteTabContent: React.FC = ({ onChange={(value) => handleStyleChangeComplete('noteOffsetX', value)} prefix="X" allowNegative + allowDecimal + decimalScale={1} min={NOTE_SETTINGS_CONSTRAINTS.noteOffsetX.min} max={NOTE_SETTINGS_CONSTRAINTS.noteOffsetX.max} placeholder="0" @@ -471,6 +473,8 @@ const NoteTabContent: React.FC = ({ onChange={(value) => handleStyleChangeComplete('noteOffsetY', value)} prefix="Y" allowNegative + allowDecimal + decimalScale={1} min={NOTE_SETTINGS_CONSTRAINTS.noteOffsetY.min} max={NOTE_SETTINGS_CONSTRAINTS.noteOffsetY.max} placeholder="0" @@ -484,7 +488,9 @@ const NoteTabContent: React.FC = ({ onChange={(value) => handleStyleChangeComplete('noteWidth', value)} suffix="px" min={1} - placeholder={`${Math.round(keyPosition.width)}px`} + allowDecimal + decimalScale={1} + placeholder={`${keyPosition.width}px`} /> @@ -642,6 +648,8 @@ const NoteTabContent: React.FC = ({ suffix="px" min={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.min} max={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.max} + allowDecimal + decimalScale={1} /> @@ -658,6 +666,8 @@ const NoteTabContent: React.FC = ({ suffix="px" min={NOTE_SETTINGS_CONSTRAINTS.borderRadius.min} max={NOTE_SETTINGS_CONSTRAINTS.borderRadius.max} + allowDecimal + decimalScale={1} /> @@ -701,6 +711,8 @@ const NoteTabContent: React.FC = ({ suffix="px" min={0} max={50} + allowDecimal + decimalScale={1} /> diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx index 422c840a..ff886dfe 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx @@ -526,6 +526,8 @@ const StyleTabContent: React.FC = ({ suffix="px" min={0} max={20} + allowDecimal + decimalScale={1} /> @@ -537,6 +539,8 @@ const StyleTabContent: React.FC = ({ suffix="px" min={0} max={100} + allowDecimal + decimalScale={1} /> @@ -595,6 +599,8 @@ const StyleTabContent: React.FC = ({ suffix="px" min={8} max={72} + allowDecimal + decimalScale={1} /> diff --git a/src/renderer/components/main/Grid/PropertiesPanel/types.ts b/src/renderer/components/main/Grid/PropertiesPanel/types.ts index af058036..0640e73c 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/types.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/types.ts @@ -63,6 +63,8 @@ export interface OptionalNumberInputProps { width?: string; placeholder?: string; allowNegative?: boolean; + allowDecimal?: boolean; + decimalScale?: number; isMixed?: boolean; mixedPlaceholder?: string; } diff --git a/src/renderer/components/main/Modal/content/settings/NoteTabContent.tsx b/src/renderer/components/main/Modal/content/settings/NoteTabContent.tsx index e07eba02..ba7851de 100644 --- a/src/renderer/components/main/Modal/content/settings/NoteTabContent.tsx +++ b/src/renderer/components/main/Modal/content/settings/NoteTabContent.tsx @@ -222,17 +222,30 @@ const NoteTabContent = forwardRef( } }; - // 글로우 크기 핸들러 + // 글로우 크기 핸들러 (소수 0.1 단위 허용) + const sanitizeGlowSize = (raw: string): string => { + const cleaned = raw.replace(/[^0-9.]/g, ''); + const dotIndex = cleaned.indexOf('.'); + if (dotIndex === -1) return cleaned; + return ( + cleaned.slice(0, dotIndex + 1) + + cleaned + .slice(dotIndex + 1) + .replace(/\./g, '') + .slice(0, 1) + ); + }; + const handleGlowSizeChange = (e: React.ChangeEvent) => { - const newValue = e.target.value.replace(/[^0-9]/g, ''); + const newValue = sanitizeGlowSize(e.target.value); setState((prev) => ({ ...prev, displayGlowSize: newValue })); }; const handleGlowSizeBlur = (e: React.FocusEvent) => { - const parsed = parseInt(e.target.value.replace(/[^0-9]/g, ''), 10); + const parsed = parseFloat(sanitizeGlowSize(e.target.value)); const clamped = Number.isNaN(parsed) ? 20 - : Math.min(Math.max(parsed, 0), 50); + : Number(Math.min(Math.max(parsed, 0), 50).toFixed(1)); setState((prev) => ({ ...prev, glowSize: clamped, @@ -391,6 +404,7 @@ const NoteTabContent = forwardRef(

{ + it('noteWidth 소수 허용', () => { + expect(keyPositionSchema.shape.noteWidth.parse(100.5)).toBe(100.5); + expect(keyPositionSchema.shape.noteWidth.parse(100)).toBe(100); + }); + + it('noteBorderRadius 소수 허용', () => { + expect(keyPositionSchema.shape.noteBorderRadius.parse(8.5)).toBe(8.5); + }); + + it('noteGlowSize 소수 허용', () => { + expect(keyPositionSchema.shape.noteGlowSize.parse(20.5)).toBe(20.5); + }); + + it('noteBorderWidth 소수 허용', () => { + expect(keyPositionSchema.shape.noteBorderWidth.parse(2.5)).toBe(2.5); + }); + + // 정수 유지 필드는 여전히 소수를 거부해야 한다 + it('noteOpacity는 소수 거부(정수 유지)', () => { + expect(() => keyPositionSchema.shape.noteOpacity.parse(80.5)).toThrow(); + }); +}); diff --git a/src/types/key/keys.ts b/src/types/key/keys.ts index e2d2298a..b07c6b3e 100644 --- a/src/types/key/keys.ts +++ b/src/types/key/keys.ts @@ -249,12 +249,11 @@ export const keyPositionSchema = z.object({ // 노트 모서리 반경 (키별 설정, 없으면 기본값 사용) noteBorderRadius: z .number() - .int() .min(NOTE_SETTINGS_CONSTRAINTS.borderRadius.min) .max(NOTE_SETTINGS_CONSTRAINTS.borderRadius.max) .optional(), // 노트 넓이(px). 비어있으면 해당 키 width를 사용(자동) - noteWidth: z.number().int().positive().optional(), + noteWidth: z.number().positive().optional(), // 노트 정렬 (left/center/right). 기본값 center. noteAlignment: z .enum(['left', 'center', 'right']) @@ -262,7 +261,7 @@ export const keyPositionSchema = z.object({ .default('center'), noteEffectEnabled: z.boolean().optional().default(true), noteGlowEnabled: z.boolean().optional().default(false), - noteGlowSize: z.number().int().min(0).max(50).optional().default(20), + noteGlowSize: z.number().min(0).max(50).optional().default(20), noteGlowOpacity: z.number().int().min(0).max(100).optional().default(70), // 그라디언트용 글로우 투명도(Top/Bottom). 없으면 noteGlowOpacity를 사용. noteGlowOpacityTop: z.number().int().min(0).max(100).optional(),