From 757b8c685a206a8dbff3ae42051e6e7e6982e6f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sat, 6 Jun 2026 20:13:21 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=B0=B0=EC=B9=98=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=EC=8B=9C=20=EB=85=B8=ED=8A=B8=20=ED=85=8C=EB=91=90?= =?UTF-8?q?=EB=A6=AC=20=EC=83=89=EC=9D=B4=20=ED=95=AD=EC=83=81=20=EC=B4=88?= =?UTF-8?q?=EB=A1=9D=EC=83=89=EC=9D=B4=20=EB=90=98=EB=8A=94=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit solidOnly 컬러피커가 rgba(...) 문자열을 반환하는데 배치 편집 저장 경로가 정규화 없이 noteBorderColor에 그대로 저장했다. 오버레이의 parseColor가 이를 hex로 착각해 substring하면서 "rgba"의 "ba"(=186)가 초록 채널로 고정돼, 어떤 색을 골라도 초록 테두리로 렌더됐다. NaN이 된 R/B 채널의 GPU 처리 차이로 Windows에서만 발현되고 Mac에서는 재현되지 않았다. - 공용 toRgbHexColor 유틸 추가: rgb/rgba/hex를 알파 버리고 #RRGGBB로 정규화 - 배치 저장 경로에서 noteBorderColor를 저장 전 정규화 - 단일 편집의 로컬 toHexColor를 공용 유틸로 통일 - parseColor가 어떤 입력이든 정규화해 NaN 방어 - 백엔드 normalize_state에서 기존에 저장된 rgba noteBorderColor를 #RRGGBB로 복구 (key/stat/graph), 변환 대상이 있으면 디스크에 영속 이슈 #73 --- src-tauri/src/state/migration.rs | 87 ++++++++++++++++++- .../components/main/Grid/PropertiesPanel.tsx | 6 +- .../PropertiesPanel/single/NoteTabContent.tsx | 21 +---- src/renderer/stores/signals/noteBuffer.ts | 4 +- src/renderer/utils/color/colorUtils.test.ts | 38 ++++++++ src/renderer/utils/color/colorUtils.ts | 23 +++++ 6 files changed, 157 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index 965aed2a..9be7dd44 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -27,7 +27,7 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result<(AppStoreData, bool)> .with_context(|| format!("failed to read store file at {}", path.display()))?; let (state, needs_persist) = match serde_json::from_str::(&content) { Ok(data) => { - let needs_persist = data.font_settings.custom_fonts.iter().any(|font| { + let needs_font_persist = data.font_settings.custom_fonts.iter().any(|font| { font.font_type == FontType::Local && font .css_content @@ -35,7 +35,12 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result<(AppStoreData, bool)> .map(|c| !c.trim().is_empty()) .unwrap_or(false) }); - (normalize_state(data), needs_persist) + // rgba로 깨진 noteBorderColor가 있으면 정규화 후 디스크에도 영속 (이슈 #73) + let needs_border_color_fix = has_convertible_note_border_color(&data); + ( + normalize_state(data), + needs_font_persist || needs_border_color_fix, + ) } // 레거시/비정상 store 파일 복구 후 정규화 상태 저장 Err(_) => (repair_legacy_state(&content), true), @@ -270,6 +275,42 @@ fn migrate_image_reference_to_app_data(images_dir: &Path, image_ref: &mut Option } } +/// `rgba(r, g, b, a)` 문자열을 `#RRGGBB`로 변환. rgba 형식이 아니거나 파싱 실패 시 None +fn rgba_to_hex(color: &str) -> Option { + let inner = color.trim().strip_prefix("rgba(")?.strip_suffix(')')?; + let mut parts = inner.split(','); + let r: u8 = parts.next()?.trim().parse().ok()?; + let g: u8 = parts.next()?.trim().parse().ok()?; + let b: u8 = parts.next()?.trim().parse().ok()?; + Some(format!("#{r:02X}{g:02X}{b:02X}")) +} + +/// noteBorderColor가 변환 가능한 rgba면 #RRGGBB로 교체. 실제 변환 여부 반환 +fn migrate_note_border_color(color: &mut Option) { + if let Some(hex) = color.as_deref().and_then(rgba_to_hex) { + *color = Some(hex); + } +} + +/// store에 #RRGGBB로 변환 가능한 rgba noteBorderColor가 남아 있는지 (디스크 영속 판단용) +fn has_convertible_note_border_color(data: &AppStoreData) -> bool { + let convertible = |color: &Option| color.as_deref().and_then(rgba_to_hex).is_some(); + data.key_positions + .values() + .flatten() + .any(|pos| convertible(&pos.note_border_color)) + || data + .stat_positions + .values() + .flatten() + .any(|stat| convertible(&stat.position.note_border_color)) + || data + .graph_positions + .values() + .flatten() + .any(|graph| convertible(&graph.position.note_border_color)) +} + /// store 데이터 정규화 및 레거시 마이그레이션 적용 pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { if data.keys.is_empty() { @@ -293,6 +334,24 @@ pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { } } + // 마이그레이션: noteBorderColor에 잘못 저장된 rgba(...) → #RRGGBB 복구 (이슈 #73) + // 배치 편집 경로가 정규화 없이 rgba 문자열을 저장해 오버레이에서 초록색으로 깨짐 + for positions in data.key_positions.values_mut() { + for pos in positions.iter_mut() { + migrate_note_border_color(&mut pos.note_border_color); + } + } + for positions in data.stat_positions.values_mut() { + for stat in positions.iter_mut() { + migrate_note_border_color(&mut stat.position.note_border_color); + } + } + for positions in data.graph_positions.values_mut() { + for graph in positions.iter_mut() { + migrate_note_border_color(&mut graph.position.note_border_color); + } + } + // 레거시 마이그레이션: fadePosition enum → 방향별 픽셀 fade 값 data.note_settings.migrate_fade_position(); for tab in data.tab_note_overrides.values_mut() { @@ -600,3 +659,27 @@ struct LegacyOverlayPosition { x: f64, y: f64, } + +#[cfg(test)] +mod tests { + use super::rgba_to_hex; + + #[test] + fn rgba_to_hex_converts_and_drops_alpha() { + assert_eq!( + rgba_to_hex("rgba(255, 0, 167, 1)").as_deref(), + Some("#FF00A7") + ); + assert_eq!( + rgba_to_hex("rgba(18, 52, 86, 0)").as_deref(), + Some("#123456") + ); + } + + #[test] + fn rgba_to_hex_ignores_non_rgba() { + assert_eq!(rgba_to_hex("#FF00A7"), None); + assert_eq!(rgba_to_hex("garbage"), None); + assert_eq!(rgba_to_hex("rgba(300, 0, 0, 1)"), None); // u8 범위 초과 + } +} diff --git a/src/renderer/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index 494dc874..da083bcb 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.tsx @@ -11,6 +11,7 @@ import { usePropertiesPanelStore } from '@stores/grid/usePropertiesPanelStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { getKeyInfoByGlobalKey } from '@utils/core/KeyMaps'; import { translatePluginMessage } from '@utils/plugin/pluginI18n'; +import { toRgbHexColor } from '@utils/color/colorUtils'; import type { KeyPosition } from '@src/types/key/keys'; import type { StatItemPosition, StatItemType } from '@src/types/key/statItems'; import type { GraphItemPosition } from '@src/types/key/graphItems'; @@ -2185,7 +2186,10 @@ const PropertiesPanel: React.FC = ({ handleBatchGlowColorChangeComplete(newColor); } } else if (batchPickerFor === 'borderColor') { - const solidColor = typeof newColor === 'string' ? newColor : '#FFFFFF'; + // noteBorderColor는 #RRGGBB 계약 — 피커의 rgba(...) 출력을 hex로 정규화 (이슈 #73) + const solidColor = toRgbHexColor( + typeof newColor === 'string' ? newColor : undefined, + ); if (selectedKeyElements.length > 0 && selectedStatElements.length > 0) { handleBatchKeyOnlyStyleChangeComplete('noteBorderColor', solidColor); } else { diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx index 365d9366..161e50e1 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx @@ -11,27 +11,12 @@ import { import Checkbox from '@components/main/common/Checkbox'; import Dropdown from '@components/main/common/Dropdown'; import ColorPicker from '@components/main/Modal/content/pickers/ColorPicker'; -import { isGradientColor } from '@utils/color/colorUtils'; +import { isGradientColor, toRgbHexColor } from '@utils/color/colorUtils'; import { NOTE_SETTINGS_CONSTRAINTS } from '@src/types/settings/noteSettingsConstraints'; import { useSettingsStore } from '@stores/useSettingsStore'; const DEFAULT_NOTE_COLOR = '#FFFFFF'; -// rgba(...) 또는 hex 문자열에서 #RRGGBB 추출 -const toHexColor = (color: string): string => { - if (color.startsWith('#') && color.length >= 7) return color.slice(0, 7); - const match = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); - if (match) { - const r = Math.min(255, Number(match[1])); - const g = Math.min(255, Number(match[2])); - const b = Math.min(255, Number(match[3])); - return `#${r.toString(16).padStart(2, '0')}${g - .toString(16) - .padStart(2, '0')}${b.toString(16).padStart(2, '0')}`.toUpperCase(); - } - return DEFAULT_NOTE_COLOR; -}; - // 색상 모드 상수 const COLOR_MODES = { solid: 'solid', @@ -740,7 +725,7 @@ const NoteTabContent: React.FC = ({ } onColorChange={(c: NoteColor) => { if (pickerFor === 'border') { - const hex = toHexColor(typeof c === 'string' ? c : '#FFFFFF'); + const hex = toRgbHexColor(typeof c === 'string' ? c : undefined); setBorderColor(hex); return; } @@ -748,7 +733,7 @@ const NoteTabContent: React.FC = ({ }} onColorChangeComplete={(c: NoteColor) => { if (pickerFor === 'border') { - const hex = toHexColor(typeof c === 'string' ? c : '#FFFFFF'); + const hex = toRgbHexColor(typeof c === 'string' ? c : undefined); setBorderColor(hex); onKeyPreview?.(keyIndex, { noteBorderColor: hex }); onKeyUpdate({ index: keyIndex, noteBorderColor: hex }); diff --git a/src/renderer/stores/signals/noteBuffer.ts b/src/renderer/stores/signals/noteBuffer.ts index 072b119d..a76254e9 100644 --- a/src/renderer/stores/signals/noteBuffer.ts +++ b/src/renderer/stores/signals/noteBuffer.ts @@ -1,4 +1,5 @@ import { DEFAULT_NOTE_BORDER_RADIUS } from '@constants/overlayDefaults'; +import { toRgbHexColor } from '@utils/color/colorUtils'; const MAX_NOTES = 2048; @@ -19,7 +20,8 @@ const linearToSRGB = (c: number) => { }; const parseColor = (hex: string) => { - const color = hex.replace('#', ''); + // rgba(...)/3·8자리 hex 등 어떤 입력이든 #RRGGBB로 정규화 — NaN 방어 (이슈 #73) + const color = toRgbHexColor(hex).slice(1); const r = parseInt(color.substring(0, 2), 16); const g = parseInt(color.substring(2, 4), 16); const b = parseInt(color.substring(4, 6), 16); diff --git a/src/renderer/utils/color/colorUtils.test.ts b/src/renderer/utils/color/colorUtils.test.ts index f3f3d133..4b43abcb 100644 --- a/src/renderer/utils/color/colorUtils.test.ts +++ b/src/renderer/utils/color/colorUtils.test.ts @@ -6,6 +6,7 @@ import { rgbToHsv, toColorObject, toCssRgba, + toRgbHexColor, } from './colorUtils'; describe('isGradientColor', () => { @@ -156,3 +157,40 @@ describe('toCssRgba', () => { expect(result.alpha).toBe(0.8); }); }); + +describe('toRgbHexColor', () => { + // 이슈 #73 회귀: rgba 문자열이 초록색으로 깨지지 않아야 함 + it('rgba 문자열을 알파 버리고 #RRGGBB로 변환', () => { + expect(toRgbHexColor('rgba(255, 0, 167, 1)')).toBe('#FF00A7'); + expect(toRgbHexColor('rgba(100, 200, 50, 0.8)')).toBe('#64C832'); + }); + + it('알파 0이어도 RGB 유지', () => { + expect(toRgbHexColor('rgba(18, 52, 86, 0)')).toBe('#123456'); + }); + + it('알파 없는 rgb()도 변환', () => { + expect(toRgbHexColor('rgb(255, 0, 167)')).toBe('#FF00A7'); + }); + + it('#RRGGBB는 대문자로 그대로', () => { + expect(toRgbHexColor('#ff00a7')).toBe('#FF00A7'); + expect(toRgbHexColor('#FF00A7')).toBe('#FF00A7'); + }); + + it('3자리/8자리 hex도 #RRGGBB로 정규화 (알파 제거)', () => { + expect(toRgbHexColor('#f0a')).toBe('#FF00AA'); + expect(toRgbHexColor('#FF00A7CC')).toBe('#FF00A7'); + }); + + it('잘못된 입력/빈값/null은 fallback', () => { + expect(toRgbHexColor(null)).toBe('#FFFFFF'); + expect(toRgbHexColor(undefined)).toBe('#FFFFFF'); + expect(toRgbHexColor('')).toBe('#FFFFFF'); + expect(toRgbHexColor('garbage')).toBe('#FFFFFF'); + }); + + it('커스텀 fallback 지원', () => { + expect(toRgbHexColor(null, '#000000')).toBe('#000000'); + }); +}); diff --git a/src/renderer/utils/color/colorUtils.ts b/src/renderer/utils/color/colorUtils.ts index 36006c34..89274f35 100644 --- a/src/renderer/utils/color/colorUtils.ts +++ b/src/renderer/utils/color/colorUtils.ts @@ -55,6 +55,28 @@ const normalizeColorInput = ( return '#561ecb'; }; +// 알파를 버리고 항상 대문자 #RRGGBB 반환. noteBorderColor처럼 hex 계약이 강제된 필드용 +const toRgbHexColor = ( + value: string | null | undefined, + fallback = '#FFFFFF', +): string => { + if (typeof value === 'string') { + const trimmed = value.trim(); + // rgb(...) / rgba(...) — 알파 버리고 0~255 클램프 + const rgb = trimmed.match( + /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/i, + ); + if (rgb) { + const toHex = (n: string) => + Math.min(255, Number(n)).toString(16).padStart(2, '0'); + return `#${toHex(rgb[1])}${toHex(rgb[2])}${toHex(rgb[3])}`.toUpperCase(); + } + const parsed = parseHexColor(trimmed); + if (parsed) return parsed.hex; + } + return fallback; +}; + const buildGradient = (topHex: string, bottomHex: string): GradientColor => ({ type: 'gradient', top: topHex, @@ -269,6 +291,7 @@ export { MODES, isGradientColor, normalizeColorInput, + toRgbHexColor, buildGradient, parseHexColor, rgbToHsv, From 63c67b71322bfb6843e2f8f223c2e4c30ecb49f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sat, 6 Jun 2026 20:35:40 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=ED=97=AC=ED=8D=BC=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=A0=95=EC=A0=95=20(=EB=B0=98=ED=99=98=EA=B0=92?= =?UTF-8?q?=20=EC=97=86=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/migration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index 9be7dd44..f846160d 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -285,7 +285,7 @@ fn rgba_to_hex(color: &str) -> Option { Some(format!("#{r:02X}{g:02X}{b:02X}")) } -/// noteBorderColor가 변환 가능한 rgba면 #RRGGBB로 교체. 실제 변환 여부 반환 +/// noteBorderColor가 변환 가능한 rgba면 #RRGGBB로 교체 (그 외 입력은 그대로) fn migrate_note_border_color(color: &mut Option) { if let Some(hex) = color.as_deref().and_then(rgba_to_hex) { *color = Some(hex);