From b4b4c35dae93bfe0d130a578ae448ea302f16e22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sat, 18 Jul 2026 02:27:05 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8=20=ED=82=A4?= =?UTF-8?q?=C2=B7=EB=85=B8=EB=B8=8C=C2=B7=EA=B7=B8=EB=9E=98=ED=94=84=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=EC=9D=84=20=EB=AC=B4=EB=B3=B4?= =?UTF-8?q?=EB=8D=94=20=EA=B8=80=EB=9E=98=EC=8A=A4=EB=A1=9C=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8=ED=95=98=EA=B3=A0=20=EC=B9=B4=EC=9A=B4=ED=84=B0=C2=B7?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EA=B8=B0=EB=B3=B8=EA=B0=92=EA=B3=BC=20?= =?UTF-8?q?=EB=88=8C=EB=A6=BC=20=ED=8A=B8=EB=9E=9C=EC=A7=80=EC=85=98=20?= =?UTF-8?q?=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/default_positions.json | 2 +- src-tauri/src/commands/preset/mod.rs | 2 +- src-tauri/src/models/mod.rs | 77 ++++++++---- .../batch/BatchCounterTabContent.tsx | 3 +- .../batch/BatchStyleTabContent.tsx | 85 ++++++++----- .../PropertiesPanel/batch/useBatchHandlers.ts | 11 +- .../single/CounterTabContent.tsx | 3 +- .../single/SingleSelectionPanel.tsx | 30 +++-- .../single/StyleTabContent.tsx | 33 +++-- .../components/main/Grid/core/Grid.tsx | 24 ++-- .../main/Grid/core/GridKeySettingModal.tsx | 2 +- .../Grid/layers/KeyCounterPreviewLayer.tsx | 5 +- .../components/main/Grid/layers/KnobItem.tsx | 20 +++- .../main/Grid/layers/StatCounterLayer.tsx | 5 +- .../editors/CounterAnimationEditorModal.tsx | 72 +++++++---- .../overlay/counters/InsideCounterLayout.tsx | 2 +- .../overlay/counters/OverlayKnobItem.tsx | 24 +++- .../components/overlay/counters/StatItem.tsx | 2 + src/renderer/components/shared/GraphPanel.tsx | 21 ++-- src/renderer/components/shared/Key.tsx | 113 ++++++------------ src/renderer/defaults.ts | 20 ++-- src/renderer/editor/model/keys.ts | 2 +- .../hooks/Grid/useGridCanvasActions.ts | 36 +++--- .../hooks/overlay/useKeyElementStyles.ts | 49 +++++--- src/renderer/styles/global.css | 30 +++++ src/renderer/utils/core/elementDefaults.ts | 32 +++++ src/types/settings/noteSettingsConstraints.ts | 6 +- 27 files changed, 458 insertions(+), 253 deletions(-) create mode 100644 src/renderer/utils/core/elementDefaults.ts diff --git a/src-tauri/default_positions.json b/src-tauri/default_positions.json index 197e2abb..c0250633 100644 --- a/src-tauri/default_positions.json +++ b/src-tauri/default_positions.json @@ -1 +1 @@ -{"4key":[{"dx":200,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#24BBB4","noteOpacity":80},{"dx":585,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#24BBB4","noteOpacity":80},{"dx":325,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":390,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":455,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":520,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80}],"5key":[{"dx":135,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#24BBB4","noteOpacity":80},{"dx":650,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#24BBB4","noteOpacity":80},{"dx":260,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":325,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":390,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":455,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":520,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":585,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80}],"6key":[{"dx":135,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#24BBB4","noteOpacity":80},{"dx":650,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#24BBB4","noteOpacity":80},{"dx":260,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":325,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":390,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":455,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":520,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":585,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80}],"8key":[{"dx":135,"dy":140,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#24BBB4","noteOpacity":80},{"dx":650,"dy":140,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#24BBB4","noteOpacity":80},{"dx":260,"dy":205,"width":190,"height":40,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#ED005C","noteOpacity":80},{"dx":455,"dy":205,"width":190,"height":40,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#ED005C","noteOpacity":80},{"dx":260,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":325,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":390,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":455,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":520,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80},{"dx":585,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":80}]} \ No newline at end of file +{"4key":[{"dx":200,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":585,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":325,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":390,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":455,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":520,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90}],"5key":[{"dx":135,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":650,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":260,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":325,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":390,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":455,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":520,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":585,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90}],"6key":[{"dx":135,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":650,"dy":165,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":260,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":325,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":390,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":455,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":520,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":585,"dy":165,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90}],"8key":[{"dx":135,"dy":140,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":650,"dy":140,"width":120,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":260,"dy":205,"width":190,"height":40,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":455,"dy":205,"width":190,"height":40,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":260,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":325,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":390,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":455,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":520,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90},{"dx":585,"dy":140,"width":60,"height":60,"activeImage":"","inactiveImage":"","count":0,"noteColor":"#FFFFFF","noteOpacity":90}]} \ No newline at end of file diff --git a/src-tauri/src/commands/preset/mod.rs b/src-tauri/src/commands/preset/mod.rs index a4fe9261..e720de18 100644 --- a/src-tauri/src/commands/preset/mod.rs +++ b/src-tauri/src/commands/preset/mod.rs @@ -575,6 +575,6 @@ mod tests { assert_eq!(position.count, 42); assert_eq!(position.height, 60.0); assert_eq!(position.note_color, NoteColor::Solid("#FFFFFF".to_string())); - assert_eq!(position.note_opacity, 80); + assert_eq!(position.note_opacity, 90); } } diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index bff87a41..3f718992 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -472,8 +472,9 @@ pub enum KeyCounterPlacement { #[serde(rename_all = "kebab-case")] #[derive(Default)] pub enum KeyCounterAlign { - #[default] Top, + // align 필드 부재 시에도 새 기본 배치와 일치하도록 serde 기본값 겸용 + #[default] Bottom, Left, Right, @@ -498,9 +499,9 @@ pub struct KeyCounterColor { impl Default for KeyCounterColor { fn default() -> Self { Self { - // 렌더러 기본값과 일치 (src/types/keys.ts) - idle: "rgba(121, 121, 121, 0.9)".to_string(), - active: "#FFFFFF".to_string(), + // 렌더러 기본 키 텍스트 색과 일치 (utils/core/elementDefaults.ts) + idle: "rgba(237, 238, 242, 0.78)".to_string(), + active: "rgba(20, 20, 24, 0.9)".to_string(), } } } @@ -786,7 +787,7 @@ impl Default for KeyCounterSettings { Self { enabled: true, placement: KeyCounterPlacement::Inside, - align: KeyCounterAlign::Top, + align: KeyCounterAlign::Bottom, align_mode: KeyCounterAlignMode::Center, fill: KeyCounterColor::default(), stroke: default_stroke_color(), @@ -811,29 +812,63 @@ impl KeyCounterSettings { /// This keeps existing user customizations intact, while fixing the old default /// active fill/stroke values (black text / outlined) that diverged from the renderer. pub fn migrate_legacy_defaults(&mut self) -> bool { + // 당시 직렬화되던 스냅샷 값 고정 — 현재 기본값 함수와 결합 금지 let looks_like_legacy_default = self.fill.idle == "#FFFFFF" && self.fill.active == "#000000" && self.stroke.idle == "#000000" && self.stroke.active == "#FFFFFF" - && self.gap == default_gap() - && self.font_size == default_counter_font_size() + && matches!(self.placement, KeyCounterPlacement::Inside) + && matches!(self.align, KeyCounterAlign::Top) + && matches!(self.align_mode, KeyCounterAlignMode::Center) + && self.gap == 6 + && self.font_size == 16 && self.font_weight == 400 && self.font_family.is_none() && !self.font_italic && !self.font_underline && !self.font_strikethrough; - if !looks_like_legacy_default { + if looks_like_legacy_default { + self.fill = KeyCounterColor::default(); + self.stroke = default_stroke_color(); + self.align = KeyCounterAlign::Bottom; + self.gap = default_gap(); + self.font_size = default_counter_font_size(); + self.font_weight = default_counter_font_weight(); + self.animation = KeyCounterAnimationSettings::default(); + self.normalize(); + return true; + } + + // 직전 기본값 스냅샷(회색 카운터·16px·700·상단 배치) → 글래스 기본 디자인으로 승격 + // 전 필드 일치일 때만 — 하나라도 다르면 사용자 커스텀으로 보고 유지 + let looks_like_previous_default = self.fill.idle == "rgba(121, 121, 121, 0.9)" + && self.fill.active == "#FFFFFF" + && self.stroke.idle == "transparent" + && self.stroke.active == "transparent" + && matches!(self.placement, KeyCounterPlacement::Inside) + && matches!(self.align, KeyCounterAlign::Top) + && matches!(self.align_mode, KeyCounterAlignMode::Center) + && self.gap == 6 + && self.font_size == 16 + && self.font_weight == 700 + && self.font_family.is_none() + && !self.font_italic + && !self.font_underline + && !self.font_strikethrough; + + if looks_like_previous_default { + self.fill = KeyCounterColor::default(); + self.align = KeyCounterAlign::Bottom; + self.gap = default_gap(); + self.font_size = default_counter_font_size(); + self.font_weight = default_counter_font_weight(); self.normalize(); - return false; + return true; } - self.fill = KeyCounterColor::default(); - self.stroke = default_stroke_color(); - self.font_weight = default_counter_font_weight(); - self.animation = KeyCounterAnimationSettings::default(); self.normalize(); - true + false } } @@ -854,13 +889,13 @@ fn default_counter_animation_duration_ms() -> u32 { } fn default_gap() -> u32 { - 6 + 4 } fn default_counter_font_size() -> u32 { - 16 + 11 } fn default_counter_font_weight() -> u32 { - 700 + 500 } fn default_counter_enabled() -> bool { @@ -876,7 +911,7 @@ fn default_key_note_color() -> NoteColor { NoteColor::Solid("#FFFFFF".to_string()) } fn default_key_note_opacity() -> u32 { - 80 + 90 } fn default_note_glow_enabled() -> bool { false @@ -960,8 +995,8 @@ impl Default for NoteSettings { Self { border_radius: None, frame_limit: default_note_frame_limit(), - speed: 180, - track_height: 150, + speed: 400, + track_height: 300, reverse: false, fade_position: FadePosition::Auto, fade_top_px: 50, @@ -2071,6 +2106,6 @@ mod tests { assert_eq!(position.count, 42); assert_eq!(position.height, 60.0); assert_eq!(position.note_color, NoteColor::Solid("#FFFFFF".to_string())); - assert_eq!(position.note_opacity, 80); + assert_eq!(position.note_opacity, 90); } } diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchCounterTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchCounterTabContent.tsx index 3e58e9c5..5e97ac5c 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchCounterTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchCounterTabContent.tsx @@ -13,6 +13,7 @@ import FontPicker from '@components/main/Modal/content/pickers/FontPicker'; import CounterAnimationPicker from '@components/main/Modal/content/pickers/CounterAnimationPicker'; import { usePanelNav } from '../PanelNavContext'; import { ColorSwatchButton } from '@components/main/Modal/content/pickers/ColorSwatch'; +import { DEFAULT_COUNTER_FONT_SIZE } from '@utils/core/elementDefaults'; // 인-패널 서브 페이지 키 — 트리거 사이트별 유니크 const FONT_PAGE_KEY = 'batch-counter:font'; @@ -257,7 +258,7 @@ const BatchCounterTabContent: React.FC = ({ {/* 폰트 크기 */} handleBatchCounterUpdate({ fontSize: value })} suffix="px" min={8} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx index eb0d78ce..efc9a02d 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx @@ -10,6 +10,16 @@ import { FontStyleToggle, } from '../index'; import Checkbox from '@components/main/common/Checkbox'; +import { + DEFAULT_ELEMENT_BG, + DEFAULT_ELEMENT_ACTIVE_BG, + DEFAULT_ELEMENT_FONT, + DEFAULT_ELEMENT_ACTIVE_FONT, + DEFAULT_ELEMENT_BORDER, + DEFAULT_ELEMENT_ACTIVE_BORDER, + DEFAULT_ELEMENT_RADIUS, + DEFAULT_ELEMENT_FONT_WEIGHT, +} from '@utils/core/elementDefaults'; import FontPicker from '@components/main/Modal/content/pickers/FontPicker'; import SoundPicker from '@components/main/Modal/content/pickers/SoundPicker'; import { usePanelNav } from '../PanelNavContext'; @@ -537,26 +547,22 @@ const BatchStyleTabContent: React.FC = ({ colorState === 'active' ? getMixedValue( (pos) => pos.activeBackgroundColor ?? pos.backgroundColor, - 'rgba(121, 121, 121, 0.9)', - ).isMixed - : getMixedValue( - (pos) => pos.backgroundColor, - 'rgba(46, 46, 47, 0.9)', + DEFAULT_ELEMENT_ACTIVE_BG, ).isMixed + : getMixedValue((pos) => pos.backgroundColor, DEFAULT_ELEMENT_BG) + .isMixed ) ? ( Mixed ) : null} pos.backgroundColor, - 'rgba(46, 46, 47, 0.9)', - ).value + getMixedValue((pos) => pos.backgroundColor, DEFAULT_ELEMENT_BG) + .value } activeValue={ getMixedValue( (pos) => pos.activeBackgroundColor ?? pos.backgroundColor, - 'rgba(121, 121, 121, 0.9)', + DEFAULT_ELEMENT_ACTIVE_BG, ).value } showStateTabs @@ -584,26 +590,22 @@ const BatchStyleTabContent: React.FC = ({ colorState === 'active' ? getMixedValue( (pos) => pos.activeBorderColor ?? pos.borderColor, - 'rgba(255, 255, 255, 0.9)', - ).isMixed - : getMixedValue( - (pos) => pos.borderColor, - 'rgba(113, 113, 113, 0.9)', + DEFAULT_ELEMENT_ACTIVE_BORDER, ).isMixed + : getMixedValue((pos) => pos.borderColor, DEFAULT_ELEMENT_BORDER) + .isMixed ) ? ( Mixed ) : null} pos.borderColor, - 'rgba(113, 113, 113, 0.9)', - ).value + getMixedValue((pos) => pos.borderColor, DEFAULT_ELEMENT_BORDER) + .value } activeValue={ getMixedValue( (pos) => pos.activeBorderColor ?? pos.borderColor, - 'rgba(255, 255, 255, 0.9)', + DEFAULT_ELEMENT_ACTIVE_BORDER, ).value } showStateTabs @@ -625,11 +627,23 @@ const BatchStyleTabContent: React.FC = ({ {/* 테두리 두께 */} - {getMixedValue((pos) => pos.borderWidth, 3).isMixed ? ( + {getMixedValue( + (pos) => + pos.borderWidth ?? + (pos.borderColor || pos.activeBorderColor ? 3 : 0), + 0, + ).isMixed ? ( Mixed ) : null} pos.borderWidth, 3).value} + value={ + getMixedValue( + (pos) => + pos.borderWidth ?? + (pos.borderColor || pos.activeBorderColor ? 3 : 0), + 0, + ).value + } onChange={(value) => handleBatchStyleChangeComplete('borderWidth', value) } @@ -643,11 +657,15 @@ const BatchStyleTabContent: React.FC = ({ {/* 모서리 반경 */} - {getMixedValue((pos) => pos.borderRadius, 10).isMixed ? ( + {getMixedValue((pos) => pos.borderRadius, DEFAULT_ELEMENT_RADIUS) + .isMixed ? ( Mixed ) : null} pos.borderRadius, 10).value} + value={ + getMixedValue((pos) => pos.borderRadius, DEFAULT_ELEMENT_RADIUS) + .value + } onChange={(value) => handleBatchStyleChangeComplete('borderRadius', value) } @@ -752,26 +770,24 @@ const BatchStyleTabContent: React.FC = ({ colorState === 'active' ? getMixedValue( (pos) => pos.activeFontColor ?? pos.fontColor, - '#FFFFFF', + DEFAULT_ELEMENT_ACTIVE_FONT, ).isMixed : getMixedValue( (pos) => pos.fontColor, - 'rgba(121, 121, 121, 0.9)', + DEFAULT_ELEMENT_FONT, ).isMixed ) ? ( Mixed ) : null} pos.fontColor, - 'rgba(121, 121, 121, 0.9)', - ).value + getMixedValue((pos) => pos.fontColor, DEFAULT_ELEMENT_FONT) + .value } activeValue={ getMixedValue( (pos) => pos.activeFontColor ?? pos.fontColor, - '#FFFFFF', + DEFAULT_ELEMENT_ACTIVE_FONT, ).value } showStateTabs @@ -799,8 +815,11 @@ const BatchStyleTabContent: React.FC = ({ > (pos.fontWeight ?? 700) >= 700, true) - .value + getMixedValue( + (pos) => + (pos.fontWeight ?? DEFAULT_ELEMENT_FONT_WEIGHT) >= 700, + true, + ).value } isItalic={getMixedValue((pos) => pos.fontItalic, false).value} isUnderline={ diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts b/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts index 6136cdac..79b2b175 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts @@ -12,12 +12,17 @@ import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; +import { + DEFAULT_ELEMENT_ACTIVE_BG, + DEFAULT_ELEMENT_ACTIVE_BORDER, + DEFAULT_ELEMENT_ACTIVE_FONT, +} from '@utils/core/elementDefaults'; import type { EditorPatchV1 } from '@src/types/editor'; -const DEFAULT_ACTIVE_BACKGROUND_COLOR = 'rgba(121, 121, 121, 0.9)'; -const DEFAULT_ACTIVE_BORDER_COLOR = 'rgba(255, 255, 255, 0.9)'; -const DEFAULT_ACTIVE_FONT_COLOR = '#FFFFFF'; +const DEFAULT_ACTIVE_BACKGROUND_COLOR = DEFAULT_ELEMENT_ACTIVE_BG; +const DEFAULT_ACTIVE_BORDER_COLOR = DEFAULT_ELEMENT_ACTIVE_BORDER; +const DEFAULT_ACTIVE_FONT_COLOR = DEFAULT_ELEMENT_ACTIVE_FONT; const SPACING_GROUP_TOLERANCE = 2; const SPACING_DECIMAL_SCALE = 1; const POSITION_CHANGE_EPSILON = 0.05; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx index 4a581fe4..453f653e 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx @@ -20,6 +20,7 @@ import FontPicker from '@components/main/Modal/content/pickers/FontPicker'; import CounterAnimationPicker from '@components/main/Modal/content/pickers/CounterAnimationPicker'; import { usePanelNav } from '../PanelNavContext'; import { ColorSwatchButton } from '@components/main/Modal/content/pickers/ColorSwatch'; +import { DEFAULT_COUNTER_FONT_SIZE } from '@utils/core/elementDefaults'; // 인-패널 서브 페이지 키 — 트리거 사이트별 유니크 const FONT_PAGE_KEY = 'single-counter:font'; @@ -315,7 +316,7 @@ const CounterTabContent: React.FC = ({ {/* 폰트 크기 */} handleCounterUpdate({ fontSize: value })} suffix="px" min={8} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx index 58fab310..2f8b3e48 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx @@ -13,6 +13,14 @@ import type { } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; import { axisEventBus } from '@utils/core/axisEventBus'; +import { + DEFAULT_ELEMENT_BG, + DEFAULT_ELEMENT_ACTIVE_BG, + DEFAULT_ELEMENT_FONT, + DEFAULT_ELEMENT_ACTIVE_FONT, + DEFAULT_ELEMENT_HAIRLINE, + DEFAULT_ELEMENT_RADIUS, +} from '@utils/core/elementDefaults'; import type { PluginSettingSchema, PluginMessages, @@ -535,8 +543,7 @@ export const SingleGraphPanel: React.FC = ({ > {}} onChangeComplete={(value) => @@ -555,8 +562,7 @@ export const SingleGraphPanel: React.FC = ({ > {}} onChangeComplete={(value) => @@ -574,7 +580,7 @@ export const SingleGraphPanel: React.FC = ({ label={t('propertiesPanel.borderWidth') || 'Border Width'} > handleGraphUpdate({ index: singleGraphIndex, @@ -591,7 +597,9 @@ export const SingleGraphPanel: React.FC = ({ label={t('propertiesPanel.borderRadius') || 'Border Radius'} > handleGraphUpdate({ index: singleGraphIndex, @@ -825,10 +833,10 @@ export const SingleKnobPanel: React.FC = ({ : t('propertiesPanel.knobAxisUnset') || '미지정'; // 대기/입력 색상 (키 패널과 동일한 기본값/전환 로직) - const DEFAULT_KNOB_BACKGROUND_COLOR = 'rgba(46, 46, 47, 0.9)'; - const DEFAULT_KNOB_BORDER_COLOR = 'rgba(113, 113, 113, 0.9)'; - const DEFAULT_KNOB_ACTIVE_BACKGROUND_COLOR = 'rgba(121, 121, 121, 0.9)'; - const DEFAULT_KNOB_ACTIVE_BORDER_COLOR = 'rgba(255, 255, 255, 0.9)'; + const DEFAULT_KNOB_BACKGROUND_COLOR = DEFAULT_ELEMENT_BG; + const DEFAULT_KNOB_BORDER_COLOR = DEFAULT_ELEMENT_FONT; + const DEFAULT_KNOB_ACTIVE_BACKGROUND_COLOR = DEFAULT_ELEMENT_ACTIVE_BG; + const DEFAULT_KNOB_ACTIVE_BORDER_COLOR = DEFAULT_ELEMENT_ACTIVE_FONT; type KnobColorTarget = 'backgroundColor' | 'borderColor'; type KnobColorProperty = @@ -1147,7 +1155,7 @@ export const SingleKnobPanel: React.FC = ({ label={t('propertiesPanel.borderWidth') || '테두리 두께'} > handleKnobUpdate({ index: singleKnobIndex, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx index 99daa694..2d9796e9 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx @@ -17,6 +17,16 @@ import FontPicker from '../../../Modal/content/pickers/FontPicker'; import SoundPicker from '../../../Modal/content/pickers/SoundPicker'; import Checkbox from '../../../common/Checkbox'; import { ColorSwatchButton } from '../../../Modal/content/pickers/ColorSwatch'; +import { + DEFAULT_ELEMENT_BG, + DEFAULT_ELEMENT_ACTIVE_BG, + DEFAULT_ELEMENT_FONT, + DEFAULT_ELEMENT_ACTIVE_FONT, + DEFAULT_ELEMENT_BORDER, + DEFAULT_ELEMENT_ACTIVE_BORDER, + DEFAULT_ELEMENT_RADIUS, + DEFAULT_ELEMENT_FONT_WEIGHT, +} from '@utils/core/elementDefaults'; // 인-패널 서브 페이지 키 — 트리거 사이트별 유니크 const FONT_PAGE_KEY = 'single-style:font'; @@ -88,12 +98,12 @@ const StyleTabContent: React.FC = ({ onLocalHeightChange, onSizeBlur, }) => { - const DEFAULT_KEY_BACKGROUND_COLOR = 'rgba(46, 46, 47, 0.9)'; - const DEFAULT_KEY_BORDER_COLOR = 'rgba(113, 113, 113, 0.9)'; - const DEFAULT_KEY_FONT_COLOR = 'rgba(121, 121, 121, 0.9)'; - const DEFAULT_KEY_ACTIVE_BACKGROUND_COLOR = 'rgba(121, 121, 121, 0.9)'; - const DEFAULT_KEY_ACTIVE_BORDER_COLOR = 'rgba(255, 255, 255, 0.9)'; - const DEFAULT_KEY_ACTIVE_FONT_COLOR = '#FFFFFF'; + const DEFAULT_KEY_BACKGROUND_COLOR = DEFAULT_ELEMENT_BG; + const DEFAULT_KEY_BORDER_COLOR = DEFAULT_ELEMENT_BORDER; + const DEFAULT_KEY_FONT_COLOR = DEFAULT_ELEMENT_FONT; + const DEFAULT_KEY_ACTIVE_BACKGROUND_COLOR = DEFAULT_ELEMENT_ACTIVE_BG; + const DEFAULT_KEY_ACTIVE_BORDER_COLOR = DEFAULT_ELEMENT_ACTIVE_BORDER; + const DEFAULT_KEY_ACTIVE_FONT_COLOR = DEFAULT_ELEMENT_ACTIVE_FONT; // 개별 편집 모드인지 확인 (로컬 상태 핸들러가 없으면 개별 편집 모드) const isIndividualMode = !onLocalDxChange; @@ -518,7 +528,10 @@ const StyleTabContent: React.FC = ({ {/* 테두리 두께 */} handleStyleChangeComplete('borderWidth', value) } @@ -533,7 +546,7 @@ const StyleTabContent: React.FC = ({ {/* 모서리 반경 */} handleStyleChangeComplete('borderRadius', value) } @@ -627,7 +640,9 @@ const StyleTabContent: React.FC = ({ {/* 글꼴 스타일 */} = 700} + isBold={ + (keyPosition.fontWeight ?? DEFAULT_ELEMENT_FONT_WEIGHT) >= 700 + } isItalic={keyPosition.fontItalic ?? false} isUnderline={keyPosition.fontUnderline ?? false} isStrikethrough={keyPosition.fontStrikethrough ?? false} diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index d50b1057..a039fdaf 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -65,6 +65,13 @@ import type { GraphItemPosition } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; import type { SaveData } from '@hooks/Modal/useUnifiedKeySettingState'; import { resolveImageSource } from '@utils/core/imageSource'; +import { + DEFAULT_ELEMENT_BG, + DEFAULT_ELEMENT_FONT, + DEFAULT_ELEMENT_HAIRLINE, + DEFAULT_ELEMENT_RADIUS, + DEFAULT_ELEMENT_SHADOW, +} from '@utils/core/elementDefaults'; import { groupSelectedElements, ungroupSelectedElements, @@ -1354,9 +1361,9 @@ const Grid = ({ width: `${width}px`, height: `${height}px`, transform: `translate3d(${offsetX}px, ${offsetY}px, 0)`, - background: 'rgba(17, 17, 20, 0.9)', - border: '1px solid rgba(255, 255, 255, 0.1)', - borderRadius: '8px', + background: DEFAULT_ELEMENT_BG, + border: `1px solid ${DEFAULT_ELEMENT_HAIRLINE}`, + borderRadius: `${DEFAULT_ELEMENT_RADIUS}px`, opacity: 0.5, zIndex: 1000, }} @@ -1378,10 +1385,7 @@ const Grid = ({ resolveImageSource(inactiveImage) || resolveImageSource(activeImage) || ''; - const backgroundColor = previewImage - ? 'transparent' - : 'rgba(46, 46, 47, 0.9)'; - const borderStyle = '3px solid rgba(113, 113, 113, 0.9)'; + const backgroundColor = previewImage ? 'transparent' : DEFAULT_ELEMENT_BG; const displayName = getKeyInfoByGlobalKey(keyName)?.displayName || keyName || ''; @@ -1399,8 +1403,8 @@ const Grid = ({ height: `${height}px`, transform: `translate3d(${offsetX}px, ${offsetY}px, 0)`, backgroundColor, - borderRadius: '10px', - border: borderStyle, + borderRadius: `${DEFAULT_ELEMENT_RADIUS}px`, + boxShadow: previewImage ? undefined : DEFAULT_ELEMENT_SHADOW, overflow: 'hidden', opacity: 0.5, zIndex: 1000, @@ -1424,7 +1428,7 @@ const Grid = ({
0 - ? `${borderWidth}px solid ${ - borderColor || 'rgba(113, 113, 113, 0.9)' - }` - : `1px solid ${borderColor || 'rgba(113, 113, 113, 0.9)'}`, + ? `${borderWidth}px solid ${borderColor || DEFAULT_ELEMENT_FONT}` + : undefined, + // 오버레이와 동일한 기본 인셋 링 섀도 — 이미지·명시 보더 노브는 제외 + boxShadow: + imageSrc || (borderWidth && borderWidth > 0) + ? undefined + : DEFAULT_ELEMENT_SHADOW, boxSizing: 'border-box', }} > @@ -287,7 +295,7 @@ const KnobItem = ({ width: '8%', height: '76%', transform: 'translateX(-50%)', - background: borderColor || 'rgba(113, 113, 113, 0.9)', + background: borderColor || DEFAULT_ELEMENT_FONT, borderRadius: '4px', }} /> diff --git a/src/renderer/components/main/Grid/layers/StatCounterLayer.tsx b/src/renderer/components/main/Grid/layers/StatCounterLayer.tsx index f14443cd..53b3d3de 100644 --- a/src/renderer/components/main/Grid/layers/StatCounterLayer.tsx +++ b/src/renderer/components/main/Grid/layers/StatCounterLayer.tsx @@ -4,6 +4,7 @@ import { computeOutsideStyle, } from '@hooks/overlay/useCounterSettings'; import { toCssRgba } from '@utils/color/colorUtils'; +import { DEFAULT_COUNTER_FONT_SIZE } from '@utils/core/elementDefaults'; interface CounterPosition { dx?: number; @@ -67,7 +68,9 @@ const StatCounter = ({ position, previewValue = 0 }: StatCounterProps) => { data-counter-state="inactive" style={ { - fontSize: `${counterSettings.fontSize ?? 16}px`, + fontSize: `${ + counterSettings.fontSize ?? DEFAULT_COUNTER_FONT_SIZE + }px`, fontFamily: counterSettings.fontFamily ? `"${counterSettings.fontFamily}", "Pretendard Variable", sans-serif` : undefined, diff --git a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx index e4cb0ff3..1a214e1c 100644 --- a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx +++ b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx @@ -16,6 +16,18 @@ import { clampCounterBezier, findBezierPresetId, } from '@utils/cubicBezier'; +import { + DEFAULT_ELEMENT_BG, + DEFAULT_ELEMENT_ACTIVE_BG, + DEFAULT_ELEMENT_FONT, + DEFAULT_ELEMENT_ACTIVE_FONT, + DEFAULT_ELEMENT_BORDER, + DEFAULT_ELEMENT_ACTIVE_BORDER, + DEFAULT_ELEMENT_RADIUS, + DEFAULT_ELEMENT_FONT_WEIGHT, + DEFAULT_COUNTER_FONT_SIZE, + DEFAULT_COUNTER_FONT_WEIGHT, +} from '@utils/core/elementDefaults'; import { useKeyStore } from '@stores/data/useKeyStore'; type EditorMode = 'create' | 'edit'; @@ -1092,14 +1104,16 @@ const CounterAnimationEditorModal = ({ const placement = counterSettings?.placement ?? 'inside'; const align = counterSettings?.align ?? 'top'; const alignMode = counterSettings?.alignMode ?? 'center'; - const gap = counterSettings?.gap ?? 6; + const gap = counterSettings?.gap ?? 4; const isInside = placement === 'inside'; const isHorizontal = align === 'left' || align === 'right'; const isBetween = alignMode === 'between'; const keyW = keyVisual?.width ?? 60; const keyH = keyVisual?.height ?? 60; - const counterExtra = (counterSettings?.fontSize ?? 16) + gap; + const counterExtra = + (counterSettings?.fontSize ?? DEFAULT_COUNTER_FONT_SIZE) + + gap; let totalW = keyW; let totalH = keyH; @@ -1129,7 +1143,8 @@ const CounterAnimationEditorModal = ({ fontFamily: keyVisual?.fontFamily ? `"${keyVisual.fontFamily}", "Pretendard Variable", sans-serif` : undefined, - fontWeight: keyVisual?.fontWeight ?? 700, + fontWeight: + keyVisual?.fontWeight ?? DEFAULT_ELEMENT_FONT_WEIGHT, fontStyle: keyVisual?.fontItalic ? 'italic' : 'normal', textDecoration: keyLabelDecorations.length > 0 @@ -1146,9 +1161,9 @@ const CounterAnimationEditorModal = ({ count={previewCount} fillColor={ previewActive - ? counterSettings?.fill.active ?? '#FFFFFF' - : counterSettings?.fill.idle ?? - 'rgba(121, 121, 121, 0.9)' + ? counterSettings?.fill.active ?? + DEFAULT_ELEMENT_ACTIVE_FONT + : counterSettings?.fill.idle ?? DEFAULT_ELEMENT_FONT } strokeColor={ previewActive @@ -1157,9 +1172,14 @@ const CounterAnimationEditorModal = ({ } globalKey="preview" active={previewActive} - fontSize={counterSettings?.fontSize ?? 16} + fontSize={ + counterSettings?.fontSize ?? DEFAULT_COUNTER_FONT_SIZE + } fontFamily={counterSettings?.fontFamily ?? null} - fontWeight={counterSettings?.fontWeight ?? 700} + fontWeight={ + counterSettings?.fontWeight ?? + DEFAULT_COUNTER_FONT_WEIGHT + } fontItalic={counterSettings?.fontItalic ?? false} fontUnderline={counterSettings?.fontUnderline ?? false} fontStrikethrough={ @@ -1187,16 +1207,24 @@ const CounterAnimationEditorModal = ({ ? keyVisual?.activeFontColor ?? keyVisual?.fontColor : keyVisual?.fontColor; const defaultBgColor = keyActive - ? 'rgba(121, 121, 121, 0.9)' - : 'rgba(46, 46, 47, 0.9)'; + ? DEFAULT_ELEMENT_ACTIVE_BG + : DEFAULT_ELEMENT_BG; const defaultBorderColor = keyActive - ? 'rgba(255, 255, 255, 0.9)' - : 'rgba(113, 113, 113, 0.9)'; + ? DEFAULT_ELEMENT_ACTIVE_BORDER + : DEFAULT_ELEMENT_BORDER; const defaultTextColor = keyActive - ? '#FFFFFF' - : 'rgba(121, 121, 121, 0.9)'; - const bw = keyVisual?.borderWidth ?? 3; - const br = keyVisual?.borderRadius ?? 10; + ? DEFAULT_ELEMENT_ACTIVE_FONT + : DEFAULT_ELEMENT_FONT; + // 명시 보더가 있을 때만 렌더 — 기본은 보더 없음(인셋 링 섀도가 담당) + // 상태별 판정: 현재 상태의 색이 있을 때만 보더 + const hasExplicitBorder = + keyVisual?.borderWidth != null + ? keyVisual.borderWidth > 0 + : stateBorderColor != null; + const explicitBorder = `${ + keyVisual?.borderWidth ?? 3 + }px solid ${stateBorderColor || defaultBorderColor}`; + const br = keyVisual?.borderRadius ?? DEFAULT_ELEMENT_RADIUS; const keyBoxStyle: React.CSSProperties = { width: `${keyW}px`, @@ -1211,11 +1239,11 @@ const CounterAnimationEditorModal = ({ ? `${br}px` : `var(--key-radius, ${br}px)`, border: useInline - ? `${bw}px solid ${ - stateBorderColor || defaultBorderColor - }` - : `var(--key-border, ${bw}px solid ${ - stateBorderColor || defaultBorderColor + ? hasExplicitBorder + ? explicitBorder + : 'none' + : `var(--key-border, ${ + hasExplicitBorder ? explicitBorder : 'none' })`, color: useInline && stateFontColor @@ -1275,7 +1303,7 @@ const CounterAnimationEditorModal = ({ } >
{ const isTransparent = isActive ? activeTransparent : idleTransparent; const stateBackground = isActive - ? activeBackgroundColor || backgroundColor || 'rgba(121, 121, 121, 0.9)' - : backgroundColor || 'rgba(46, 46, 47, 0.9)'; + ? activeBackgroundColor || backgroundColor || DEFAULT_ELEMENT_ACTIVE_BG + : backgroundColor || DEFAULT_ELEMENT_BG; + // 보더 색은 회전 인식 막대 색을 겸함 — 폴백은 텍스트 색 계열 const stateBorderColor = isActive - ? activeBorderColor || borderColor || 'rgba(255, 255, 255, 0.9)' - : borderColor || 'rgba(113, 113, 113, 0.9)'; + ? activeBorderColor || borderColor || DEFAULT_ELEMENT_ACTIVE_FONT + : borderColor || DEFAULT_ELEMENT_FONT; // 모서리 반경 미지정 시 원형 유지 (px 지정 시 키와 동일한 px 단위) const resolvedRadius = borderRadius != null ? `${borderRadius}px` : '50%'; @@ -154,6 +163,13 @@ const OverlayKnobItem = ({ position, index = 0 }: OverlayKnobItemProps) => { borderWidth && borderWidth > 0 ? `${borderWidth}px solid ${stateBorderColor}` : undefined, + // 기본 표면은 키와 같은 인셋 링 섀도 — 이미지·투명·명시 보더(구형 저장 데이터 포함)는 제외 + boxShadow: + isTransparent || imageSrc || (borderWidth && borderWidth > 0) + ? undefined + : isActive + ? DEFAULT_ELEMENT_ACTIVE_SHADOW + : DEFAULT_ELEMENT_SHADOW, boxSizing: 'border-box', transform: `rotate(${angle}deg)`, transition: 'transform 0.1s linear', diff --git a/src/renderer/components/overlay/counters/StatItem.tsx b/src/renderer/components/overlay/counters/StatItem.tsx index 250b0a0b..e719d26e 100644 --- a/src/renderer/components/overlay/counters/StatItem.tsx +++ b/src/renderer/components/overlay/counters/StatItem.tsx @@ -64,6 +64,8 @@ const StatItem = React.memo( className={`absolute ${position.className || ''}`} style={keyStyle} data-state={active ? 'active' : 'inactive'} + data-key-element="true" + data-key-image={hasCurrentImage ? 'true' : undefined} > {hasCurrentImage ? ( ( graphType = 'line', graphColor = '#86EFAC', showAvgLine = true, - backgroundColor = 'rgba(17, 17, 20, 0.9)', - borderColor = 'rgba(255, 255, 255, 0.1)', - borderWidth = 3, - borderRadius = 8, + backgroundColor = DEFAULT_ELEMENT_BG, + borderColor = DEFAULT_ELEMENT_HAIRLINE, + borderWidth = 1, + borderRadius = DEFAULT_ELEMENT_RADIUS, imageSrc = null, imageFit = 'cover', useInlineStyles = false, @@ -376,17 +381,17 @@ const GraphPanel = forwardRef( const resolvedBorderWidth = Number.isFinite(Number(borderWidth)) ? Math.max(0, Number(borderWidth)) - : 3; + : 1; const resolvedBorderRadius = Number.isFinite(Number(borderRadius)) ? Math.max(0, Number(borderRadius)) - : 8; + : DEFAULT_ELEMENT_RADIUS; const useInline = useInlineStyles === true; - const resolvedBackgroundColor = backgroundColor || 'rgba(17, 17, 20, 0.9)'; + const resolvedBackgroundColor = backgroundColor || DEFAULT_ELEMENT_BG; const fallbackBorder = resolvedBorderWidth <= 0 ? 'none' : `${resolvedBorderWidth}px solid ${ - borderColor || 'rgba(255, 255, 255, 0.1)' + borderColor || DEFAULT_ELEMENT_HAIRLINE }`; const resolvedBorder = useInline ? fallbackBorder diff --git a/src/renderer/components/shared/Key.tsx b/src/renderer/components/shared/Key.tsx index f4ad3634..221e1a86 100644 --- a/src/renderer/components/shared/Key.tsx +++ b/src/renderer/components/shared/Key.tsx @@ -21,8 +21,14 @@ import { computeKeyElementStyles, type KeyElementPosition, } from '@hooks/overlay/useKeyElementStyles'; +import { + DEFAULT_ELEMENT_BG, + DEFAULT_ELEMENT_FONT, + DEFAULT_ELEMENT_BORDER, + DEFAULT_ELEMENT_RADIUS, + DEFAULT_ELEMENT_FONT_WEIGHT, +} from '@utils/core/elementDefaults'; import InsideCounterLayout from '@components/overlay/counters/InsideCounterLayout'; -import CountDisplay from '@components/overlay/counters/CountDisplay'; // DraggableKey에서 counter가 KeyCounterSettings 타입인 확장 position interface KeyPosition extends KeyElementPosition { @@ -265,6 +271,14 @@ const DraggableKey = React.memo( const shouldPromoteTransformLayer = isDraggingOrResizing || isViewportTransforming; + // 명시 보더가 있을 때만 렌더 — 기본은 보더 없음(인셋 링 섀도가 담당) + // 에디터는 항상 대기 상태 — 오버레이의 상태별 판정과 동일하게 idle 색만 본다 + const hasExplicitBorder = + borderWidth != null ? borderWidth > 0 : borderColor != null; + const explicitBorder = `${borderWidth ?? 3}px solid ${ + borderColor || DEFAULT_ELEMENT_BORDER + }`; + const keyStyle = { width: `${width}px`, height: `${height}px`, @@ -275,22 +289,21 @@ const DraggableKey = React.memo( : `var(--key-bg, ${ inactiveImageSrc ? 'transparent' - : backgroundColor || 'rgba(46, 46, 47, 0.9)' + : backgroundColor || DEFAULT_ELEMENT_BG })`, borderRadius: useInline && borderRadius != null ? `${borderRadius}px` : `var(--key-radius, ${ - borderRadius != null ? `${borderRadius}px` : '10px' - })`, - border: - useInline && (borderColor || borderWidth != null) - ? `${borderWidth ?? 3}px solid ${ - borderColor || 'rgba(113, 113, 113, 0.9)' - }` - : `var(--key-border, ${borderWidth ?? 3}px solid ${ - borderColor || 'rgba(113, 113, 113, 0.9)' + borderRadius != null + ? `${borderRadius}px` + : `${DEFAULT_ELEMENT_RADIUS}px` })`, + border: useInline + ? hasExplicitBorder + ? explicitBorder + : 'none' + : `var(--key-border, ${hasExplicitBorder ? explicitBorder : 'none'})`, overflow: 'hidden' as const, willChange: shouldPromoteTransformLayer ? 'transform' : 'auto', contain: 'layout style paint', @@ -319,12 +332,12 @@ const DraggableKey = React.memo( color: useInline && fontColor ? fontColor - : `var(--key-text-color, ${fontColor || 'rgba(121, 121, 121, 0.9)'})`, + : `var(--key-text-color, ${fontColor || DEFAULT_ELEMENT_FONT})`, fontSize: fontSize ? `${fontSize}px` : undefined, fontFamily: fontFamily ? `"${fontFamily}", "Pretendard Variable", sans-serif` : undefined, - fontWeight: fontWeight ?? 700, + fontWeight: fontWeight ?? DEFAULT_ELEMENT_FONT_WEIGHT, fontStyle: fontItalic ? 'italic' : 'normal', textDecoration: textDecorations.length > 0 ? textDecorations.join(' ') : 'none', @@ -332,12 +345,7 @@ const DraggableKey = React.memo( if (position?.hidden) return null; - const counterFillColor = counterSettings.fill.idle; - const counterStrokeColor = counterSettings.stroke.idle; - const contentGap = Number.isFinite(counterSettings.gap) - ? counterSettings.gap - : 6; - + // 오버레이와 동일 컴포넌트로 렌더 — 에디터/오버레이 텍스트 배치 싱크 보장 const renderInsideCounterPreview = () => { if (!showInsideCounter) { return null; @@ -346,69 +354,15 @@ const DraggableKey = React.memo( const displayValue = (counterValueSignal?.value ?? counterPreviewValue ?? 0) | 0; - const counterElement = ( - ); - - const nameElement = ( - - {labelText} - - ); - - const isHorizontal = - counterSettings.align === 'left' || counterSettings.align === 'right'; - - const elements = isHorizontal - ? counterSettings.align === 'left' - ? [counterElement, nameElement] - : [nameElement, counterElement] - : counterSettings.align === 'top' - ? [counterElement, nameElement] - : [nameElement, counterElement]; - - const alignMode = counterSettings.alignMode || 'center'; - const isBetween = alignMode === 'between'; - const containerClass = `flex ${ - isHorizontal ? '' : 'flex-col' - } w-full h-full items-center pointer-events-none select-none`; - - return ( -
- {elements} -
- ); }; const attachRef = (node: HTMLElement | null) => { @@ -429,6 +383,7 @@ const DraggableKey = React.memo( data-state="inactive" data-editing={isDraggingOrResizing ? 'true' : undefined} data-key-element="true" + data-key-image={inactiveImageSrc ? 'true' : undefined} onClick={handleClick} onDoubleClick={onDoubleClick ? handleDoubleClick : undefined} onPointerDown={ @@ -509,6 +464,8 @@ export const Key = React.memo(function Key({ className={`absolute ${position.className || ''}`} style={keyStyle} data-state={active ? 'active' : 'inactive'} + data-key-element="true" + data-key-image={hasCurrentImage ? 'true' : undefined} > {hasCurrentImage ? ( 0 : stateBorderColor != null; + const explicitBorder = `${borderWidth ?? 3}px solid ${ + stateBorderColor || defaultBorderColor + }`; const keyStyle: React.CSSProperties = { width: `${width}px`, @@ -143,16 +163,15 @@ export function computeKeyElementStyles({ useInline && borderRadius != null ? `${borderRadius}px` : `var(--key-radius, ${ - borderRadius != null ? `${borderRadius}px` : '10px' - })`, - border: - useInline && (stateBorderColor || borderWidth != null) - ? `${borderWidth ?? 3}px solid ${ - stateBorderColor || defaultBorderColor - }` - : `var(--key-border, ${borderWidth ?? 3}px solid ${ - stateBorderColor || defaultBorderColor + borderRadius != null + ? `${borderRadius}px` + : `${DEFAULT_ELEMENT_RADIUS}px` })`, + border: useInline + ? hasExplicitBorder + ? explicitBorder + : 'none' + : `var(--key-border, ${hasExplicitBorder ? explicitBorder : 'none'})`, color: useInline && stateFontColor ? stateFontColor @@ -194,7 +213,7 @@ export function computeKeyElementStyles({ fontFamily: fontFamily ? `"${fontFamily}", "Pretendard Variable", sans-serif` : undefined, - fontWeight: fontWeight ?? 700, + fontWeight: fontWeight ?? DEFAULT_ELEMENT_FONT_WEIGHT, fontStyle: fontItalic ? ('italic' as const) : ('normal' as const), textDecoration: textDecorations.length > 0 ? textDecorations.join(' ') : 'none', diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css index ab9b700b..2e7a775a 100644 --- a/src/renderer/styles/global.css +++ b/src/renderer/styles/global.css @@ -70,6 +70,7 @@ input[type='number']::-webkit-outer-spin-button { box-sizing: border-box; padding-inline: var(--dmn-counter-safe-inline, 1px); white-space: nowrap; + letter-spacing: 0.06em; color: var(--counter-color, var(--counter-color-default, #ffffff)); } @@ -94,6 +95,35 @@ input[type='number']::-webkit-outer-spin-button { padding-inline: var(--dmn-text-safe-inline, 2px); } +/* + * 기본 키 디자인 — 보더 대신 인셋 링 + 상단 하이라이트가 표면 분리 담당 + * 커스텀 CSS([data-state] 셀렉터, head 뒤쪽 주입)가 덮어쓸 수 있도록 + * :where로 특이도를 동률 이하로 유지. 색·섀도 값은 + * utils/core/elementDefaults.ts와 동기 유지 + */ +:where([data-key-element]) { + transition: background-color 100ms ease-out, color 100ms ease-out, + box-shadow 120ms ease-out; +} + +/* 누름은 즉시 — 트랜지션은 도착 상태의 규칙이 결정하므로 active에서 끄면 + 눌림은 0ms, 복귀만 위 페이드를 탄다 (고KPS 연타에서 히트가 뭉개지지 않게) */ +:where([data-key-element])[data-state='active'] { + transition: none; +} + +/* 이미지 키는 표면이 투명이라 섀도 제외 */ +:where([data-key-element]:not([data-key-image]))[data-state='inactive'] { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07), + inset 0 1px 0 0 rgba(255, 255, 255, 0.1), 0 4px 10px rgba(0, 0, 0, 0.28); +} + +:where([data-key-element]:not([data-key-image]))[data-state='active'] { + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.32), + inset 0 1px 2px rgba(255, 255, 255, 0.5), + inset 0 -1px 3px rgba(0, 0, 0, 0.12); +} + /* * 드래그/리사이즈 중 애니메이션 비활성화 * 커스텀 CSS에서 transition이나 animation이 적용된 경우, diff --git a/src/renderer/utils/core/elementDefaults.ts b/src/renderer/utils/core/elementDefaults.ts new file mode 100644 index 00000000..a4177123 --- /dev/null +++ b/src/renderer/utils/core/elementDefaults.ts @@ -0,0 +1,32 @@ +/** + * 기본 제공 요소(키·노브·그래프·통계)의 기본 팔레트 단일 소스 + * 보더 없는 글래스 표면 — 분리는 인셋 링 + 상단 하이라이트 섀도가 담당 + * 색감은 tokens.css의 무채색 스모크 계열(--ui-glass, --ui-fg)과 동조 + * global.css의 [data-key-element] 기본 규칙과 값 동기 유지 + */ + +export const DEFAULT_ELEMENT_BG = 'rgba(14, 14, 17, 0.72)'; +export const DEFAULT_ELEMENT_ACTIVE_BG = 'rgba(255, 255, 255, 0.88)'; +export const DEFAULT_ELEMENT_FONT = 'rgba(237, 238, 242, 0.78)'; +export const DEFAULT_ELEMENT_ACTIVE_FONT = 'rgba(20, 20, 24, 0.9)'; + +// 기본 상태는 보더 없음 — 사용자가 보더를 켤 때의 색 폴백 +export const DEFAULT_ELEMENT_BORDER = 'rgba(113, 113, 113, 0.9)'; +export const DEFAULT_ELEMENT_ACTIVE_BORDER = 'rgba(255, 255, 255, 0.9)'; + +// 그래프 등 실보더 요소가 인셋 링을 흉내낼 때 쓰는 헤어라인 색 +export const DEFAULT_ELEMENT_HAIRLINE = 'rgba(255, 255, 255, 0.07)'; + +export const DEFAULT_ELEMENT_RADIUS = 4; +export const DEFAULT_ELEMENT_FONT_WEIGHT = 700; + +// 카운터 타이포 — 자간(0.06em)은 global.css .counter가 소유 +// 색은 키 텍스트와 동일 (백엔드 KeyCounterColor::default와 동기 유지) +export const DEFAULT_COUNTER_FONT_SIZE = 11; +export const DEFAULT_COUNTER_FONT_WEIGHT = 500; + +// 비활성: 인셋 링 + 상단 하이라이트 + 소프트 드롭 / 활성: 눌림 인셋 +export const DEFAULT_ELEMENT_SHADOW = + 'inset 0 0 0 1px rgba(255, 255, 255, 0.07), inset 0 1px 0 0 rgba(255, 255, 255, 0.1), 0 4px 10px rgba(0, 0, 0, 0.28)'; +export const DEFAULT_ELEMENT_ACTIVE_SHADOW = + '0 3px 8px rgba(0, 0, 0, 0.32), inset 0 1px 2px rgba(255, 255, 255, 0.5), inset 0 -1px 3px rgba(0, 0, 0, 0.12)'; diff --git a/src/types/settings/noteSettingsConstraints.ts b/src/types/settings/noteSettingsConstraints.ts index 3b9a13df..4edb6c81 100644 --- a/src/types/settings/noteSettingsConstraints.ts +++ b/src/types/settings/noteSettingsConstraints.ts @@ -7,7 +7,7 @@ export const NOTE_SETTINGS_CONSTRAINTS = { borderRadius: { min: 1, max: 100, - default: 2, + default: 4, }, frameLimit: { min: 0, @@ -17,12 +17,12 @@ export const NOTE_SETTINGS_CONSTRAINTS = { speed: { min: 70, max: 9999, - default: 180, + default: 400, }, trackHeight: { min: 20, max: 2000, - default: 150, + default: 300, }, shortNoteThresholdMs: { min: 0, From 2640097bf69039186b88d9381977eab489b91a83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sat, 18 Jul 2026 14:44:14 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=ED=82=A4=C2=B7=EC=B9=B4=EC=9A=B4?= =?UTF-8?q?=ED=84=B0=20=EC=83=89=EC=83=81=EC=97=90=20=EA=B7=B8=EB=9D=BC?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=EC=85=98=20=ED=98=95=EC=8B=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=E2=80=94=20=ED=94=BC=EC=BB=A4=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=C2=B7=EC=98=A8=EC=BA=94=EB=B2=84=EC=8A=A4?= =?UTF-8?q?=20=EC=B6=95=20=ED=8E=B8=EC=A7=91=C2=B7=ED=8C=94=EB=A0=88?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=80=EC=9E=A5=C2=B7=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EB=A0=8C=EB=8D=94=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/custom-css/variables/page.mdx | 5 +- docs/content/ko/custom-css/variables/page.mdx | 5 +- src-tauri/src/commands/preset/load.rs | 5 + src-tauri/src/commands/preset/mod.rs | 80 +- src-tauri/src/commands/preset/save.rs | 6 + src-tauri/src/models/mod.rs | 567 ++++++++++++- src-tauri/src/state/migration.rs | 244 +++++- src-tauri/src/state/store.rs | 140 +++- .../store/gradient_real_data_simulation.rs | 760 ++++++++++++++++++ .../__tests__/gradientAxisHandle.test.tsx | 250 ++++++ .../components/main/Grid/PropertiesPanel.tsx | 4 + .../Grid/PropertiesPanel/PropertyInputs.tsx | 71 +- .../batch/BatchSelectionPanel.tsx | 22 + .../batch/BatchStyleTabContent.tsx | 54 +- .../PropertiesPanel/batch/useBatchHandlers.ts | 89 ++ .../single/CounterTabContent.tsx | 96 ++- .../single/SingleSelectionPanel.tsx | 138 +++- .../single/StyleTabContent.tsx | 142 +++- .../main/Grid/PropertiesPanel/types.ts | 11 + .../components/main/Grid/core/Grid.tsx | 27 +- .../main/Grid/handles/GradientAxisHandle.tsx | 730 +++++++++++++++++ .../components/main/Grid/layers/GraphItem.tsx | 5 + .../Grid/layers/KeyCounterPreviewLayer.tsx | 12 + .../components/main/Grid/layers/KnobItem.tsx | 26 +- .../main/Grid/layers/StatCounterLayer.tsx | 12 + .../components/main/Modal/FloatingPopup.tsx | 14 + .../editors/CounterAnimationEditorModal.tsx | 92 ++- .../Modal/content/pickers/ColorPicker.tsx | 156 +++- .../Modal/content/pickers/ColorSwatch.tsx | 14 +- .../pickers/CounterAnimationPicker.tsx | 5 + .../pickers/GradientFormatControls.tsx | 380 +++++++++ .../content/pickers/colorPickerPrimitives.tsx | 3 +- .../overlay/counters/CountDisplay.tsx | 12 + .../overlay/counters/InsideCounterLayout.tsx | 4 + .../overlay/counters/OutsideCounter.tsx | 4 + .../overlay/counters/OverlayGraphItem.tsx | 7 + .../overlay/counters/OverlayKnobItem.tsx | 45 +- .../components/overlay/counters/StatItem.tsx | 9 +- src/renderer/components/shared/GraphPanel.tsx | 45 +- src/renderer/components/shared/Key.tsx | 66 +- .../editor/runtime/editorCoordinator.ts | 19 +- .../hooks/Modal/useUnifiedKeySettingState.ts | 21 +- .../hooks/overlay/useKeyElementStyles.ts | 119 ++- .../hooks/pickers/useGradientColorState.tsx | 232 ++++++ src/renderer/locales/en.json | 1 + src/renderer/locales/ko.json | 1 + src/renderer/locales/ru.json | 1 + src/renderer/locales/zh-Hant.json | 1 + src/renderer/locales/zh-cn.json | 1 + .../stores/grid/useGradientEditStore.ts | 31 + src/renderer/styles/global.css | 15 +- .../utils/color/colorPaletteStorage.ts | 40 +- src/renderer/utils/core/elementDefaults.ts | 17 +- src/types/color.test.ts | 241 ++++++ src/types/color.ts | 320 ++++++++ src/types/editor.ts | 53 ++ src/types/key/keys.ts | 16 + 57 files changed, 5323 insertions(+), 163 deletions(-) create mode 100644 src-tauri/src/state/store/gradient_real_data_simulation.rs create mode 100644 src/renderer/__tests__/gradientAxisHandle.test.tsx create mode 100644 src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx create mode 100644 src/renderer/components/main/Modal/content/pickers/GradientFormatControls.tsx create mode 100644 src/renderer/hooks/pickers/useGradientColorState.tsx create mode 100644 src/renderer/stores/grid/useGradientEditStore.ts create mode 100644 src/types/color.test.ts create mode 100644 src/types/color.ts diff --git a/docs/content/en/custom-css/variables/page.mdx b/docs/content/en/custom-css/variables/page.mdx index dbbb0d4b..b04091d3 100644 --- a/docs/content/en/custom-css/variables/page.mdx +++ b/docs/content/en/custom-css/variables/page.mdx @@ -15,6 +15,7 @@ CSS variables applied to key elements. Use with `[data-state="inactive"]` or `[d | ------------------ | ------------------------ | ---------- | ------------------------ | | `--key-radius` | Corner roundness | `` | `8px`, `50%` | | `--key-bg` | Background color | `` | `#ff2b80` | +| `--key-bg-image` | Background image (gradient) | `` | `linear-gradient(90deg, #f0f, #0ff)` | | `--key-border` | Border style | `` | `2px solid #fff`, `none` | | `--key-text-color` | Text color | `` | `#ffffff` | | `--key-offset-x` | X offset in active state | `` | `0px`, `2px` | @@ -23,7 +24,9 @@ CSS variables applied to key elements. Use with `[data-state="inactive"]` or `[d `--key-bg` is applied as `background-color`. To use gradients, specify `background` or `background-image` directly and set `--key-bg: transparent;` - if needed. + if needed. When a key color is set to a gradient in the app, it renders as + `background-image: var(--key-bg-image, ...)`, so you can override it with + `--key-bg-image` (solid keys never get an inline `background-image`). ### Usage Example diff --git a/docs/content/ko/custom-css/variables/page.mdx b/docs/content/ko/custom-css/variables/page.mdx index 18e3bac3..2bc30022 100644 --- a/docs/content/ko/custom-css/variables/page.mdx +++ b/docs/content/ko/custom-css/variables/page.mdx @@ -15,6 +15,7 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 | ------------------ | ------------------------ | ---------- | ------------------------ | | `--key-radius` | 키 모서리의 둥근 정도 | `` | `8px`, `50%` | | `--key-bg` | 키의 배경색 | `` | `#ff2b80` | +| `--key-bg-image` | 키의 배경 이미지(그라데이션) | `` | `linear-gradient(90deg, #f0f, #0ff)` | | `--key-border` | 키의 테두리 스타일 | `` | `2px solid #fff`, `none` | | `--key-text-color` | 키 텍스트 색상 | `` | `#ffffff` | | `--key-offset-x` | 활성 상태에서 X축 이동량 | `` | `0px`, `2px` | @@ -23,7 +24,9 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 `--key-bg`는 `background-color`로 적용됩니다. 그라데이션을 사용하려면 `background` 또는 `background-image`를 직접 지정하고, 필요하면 `--key-bg: - transparent;`로 설정하세요. + transparent;`로 설정하세요. 앱에서 키 색상을 그라데이션으로 설정한 경우에는 + `background-image: var(--key-bg-image, ...)`로 렌더되므로 `--key-bg-image`로 + 덮어쓸 수 있습니다 (단색 키에는 인라인 `background-image`가 붙지 않습니다). ### 사용 예시 diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index a818874c..6e0e20ad 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -55,6 +55,11 @@ fn read_preset_file(path: &Path) -> CmdResult { serde_json::from_str(&content).map_err(|_| CommandError::msg("invalid-preset")) } +#[cfg(test)] +pub(crate) fn read_preset_file_for_simulation(path: &Path) -> CmdResult { + read_preset_file(path) +} + struct ResolvedFullPresetSettings { background_color: String, note_settings: NoteSettings, diff --git a/src-tauri/src/commands/preset/mod.rs b/src-tauri/src/commands/preset/mod.rs index e720de18..60f0281c 100644 --- a/src-tauri/src/commands/preset/mod.rs +++ b/src-tauri/src/commands/preset/mod.rs @@ -243,7 +243,8 @@ mod tests { // file URL 경로 테스트가 유닉스 전용이라 Windows에선 미사용 경고 방지 #[cfg(not(target_os = "windows"))] use super::local_source_path_from_image_ref; - use crate::models::NoteColor; + use crate::models::{KeyCounterColor, NoteColor}; + use serde::Deserialize; use serde_json::json; #[test] @@ -273,6 +274,83 @@ mod tests { assert!(preset.tab_css_overrides.is_none()); } + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct PreFeaturePresetPosition { + background_color: Option, + counter: PreFeaturePresetCounter, + } + + #[derive(Deserialize)] + struct PreFeaturePresetCounter { + fill: KeyCounterColor, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct PreFeaturePreset { + key_positions: Option>>, + } + + #[test] + fn gradient_preset_round_trip_and_pre_feature_shadow_preserve_representative_colors() { + let source = json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": { + "4key": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "backgroundColor": "rgba(16, 32, 48, 1)", + "backgroundGradient": { + "angle": 90, + "stops": [ + { "color": "rgba(16, 32, 48, 1)", "pos": 0 }, + { "color": "rgba(64, 80, 96, 0.5)", "pos": 1 } + ] + }, + "counter": { + "fill": { + "idle": "rgba(255,255,255,1)", + "active": "rgba(20,20,24,0.9)" + }, + "fillIdleGradient": { + "angle": 180, + "stops": [ + { "color": "#FFFFFF", "pos": 0 }, + { "color": "#000000", "pos": 1 } + ] + } + } + }] + } + }); + let preset: PresetFile = serde_json::from_value(source).unwrap(); + let serialized = serde_json::to_value(&preset).unwrap(); + let restored: PresetFile = serde_json::from_value(serialized.clone()).unwrap(); + let reserialized = serde_json::to_value(restored).unwrap(); + + assert_eq!(reserialized, serialized); + assert_eq!( + serialized["keyPositions"]["4key"][0]["backgroundGradient"]["angle"].as_f64(), + Some(90.0) + ); + assert_eq!( + serialized["keyPositions"]["4key"][0]["counter"]["fillIdleGradient"]["angle"].as_f64(), + Some(180.0) + ); + + let shadow: PreFeaturePreset = serde_json::from_value(serialized).unwrap(); + let shadow_positions = shadow.key_positions.unwrap(); + let shadow_position = &shadow_positions["4key"][0]; + assert_eq!( + shadow_position.background_color.as_deref(), + Some("rgba(16, 32, 48, 1)") + ); + assert_eq!(shadow_position.counter.fill.idle, "rgba(255,255,255,1)"); + } + #[test] fn tauri_era_preset_schema_transitions_remain_readable() { let tauri_130: PresetFile = serde_json::from_value(json!({ diff --git a/src-tauri/src/commands/preset/save.rs b/src-tauri/src/commands/preset/save.rs index d1e2e348..32abf7d0 100644 --- a/src-tauri/src/commands/preset/save.rs +++ b/src-tauri/src/commands/preset/save.rs @@ -247,6 +247,12 @@ fn write_preset_file(path: &Path, json: &str) -> CmdResult<()> { Ok(()) } +#[cfg(test)] +pub(crate) fn write_preset_file_for_simulation(path: &Path, preset: &PresetFile) -> CmdResult<()> { + let json = serde_json::to_string_pretty(preset)?; + write_preset_file(path, &json) +} + fn collect_used_font_families( key_positions: &KeyPositions, stat_positions: &StatPositions, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 3f718992..5f5a8434 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -4,7 +4,7 @@ pub mod obs; pub use editor::*; use serde::de::Error as DeError; -use serde::ser::SerializeMap; +use serde::ser::{Error as SerError, SerializeMap}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::collections::HashMap; use std::path::Path; @@ -17,6 +17,117 @@ pub type StatPositions = HashMap>; pub type GraphPositions = HashMap>; pub type KnobPositions = HashMap>; +const DEFAULT_GRADIENT_ANGLE: f64 = 90.0; +const MAX_GRADIENT_STOPS: usize = 8; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GradientStop { + pub color: String, + pub pos: f64, +} + +#[derive(Debug, Clone)] +pub struct GradientSpec { + pub angle: f64, + pub stops: Vec, + normalized_on_read: bool, +} + +impl PartialEq for GradientSpec { + fn eq(&self, other: &Self) -> bool { + self.angle == other.angle && self.stops == other.stops + } +} + +impl GradientSpec { + fn normalize(&mut self) -> bool { + let previous_angle = self.angle; + let previous_stops = self.stops.clone(); + + self.angle = self.angle.rem_euclid(360.0); + if self.angle == 0.0 { + self.angle = 0.0; + } + for stop in &mut self.stops { + stop.pos = stop.pos.clamp(0.0, 1.0); + if stop.pos == 0.0 { + stop.pos = 0.0; + } + } + self.stops + .sort_by(|left, right| left.pos.total_cmp(&right.pos)); + self.stops.truncate(MAX_GRADIENT_STOPS); + + self.angle != previous_angle || self.stops != previous_stops + } + + fn canonicalize(&mut self) -> bool { + let normalized_on_read = std::mem::take(&mut self.normalized_on_read); + self.normalize() | normalized_on_read + } +} + +impl Serialize for GradientSpec { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if self.stops.len() < 2 { + return Err(S::Error::custom("gradient must contain at least two stops")); + } + if !self.angle.is_finite() || self.stops.iter().any(|stop| !stop.pos.is_finite()) { + return Err(S::Error::custom("gradient values must be finite")); + } + + let mut canonical = self.clone(); + canonical.normalize(); + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("angle", &canonical.angle)?; + map.serialize_entry("stops", &canonical.stops)?; + map.end() + } +} + +impl<'de> Deserialize<'de> for GradientSpec { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let serde_json::Value::Object(mut input) = value else { + return Err(D::Error::custom("gradient must be an object")); + }; + let angle_value = input.remove("angle"); + let angle = match angle_value.as_ref() { + Some(value) => serde_json::from_value::(value.clone()) + .map_err(|error| D::Error::custom(format!("invalid gradient angle: {error}")))?, + None => DEFAULT_GRADIENT_ANGLE, + }; + let stops = input + .remove("stops") + .ok_or_else(|| D::Error::custom("gradient stops are required")) + .and_then(|value| { + serde_json::from_value::>(value) + .map_err(|error| D::Error::custom(format!("invalid gradient stops: {error}"))) + })?; + if stops.len() < 2 { + return Err(D::Error::custom("gradient must contain at least two stops")); + } + if !angle.is_finite() || stops.iter().any(|stop| !stop.pos.is_finite()) { + return Err(D::Error::custom("gradient values must be finite")); + } + + let mut gradient = Self { + angle, + stops, + normalized_on_read: angle_value.is_none() || !input.is_empty(), + }; + gradient.normalized_on_read |= gradient.normalize(); + Ok(gradient) + } +} + #[derive(Debug, Clone, PartialEq)] pub enum NoteColor { Solid(String), @@ -270,12 +381,20 @@ pub struct KeyPosition { // 스타일 관련 속성들 #[serde(default)] pub background_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background_gradient: Option, #[serde(default)] pub active_background_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_background_gradient: Option, #[serde(default)] pub border_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub border_gradient: Option, #[serde(default)] pub active_border_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_border_gradient: Option, #[serde(default)] pub border_width: Option, #[serde(default)] @@ -365,9 +484,13 @@ impl Default for KeyPosition { z_index: None, counter: KeyCounterSettings::default(), background_color: None, + background_gradient: None, active_background_color: None, + active_background_gradient: None, border_color: None, + border_gradient: None, active_border_color: None, + active_border_gradient: None, border_width: None, border_radius: None, font_size: None, @@ -753,6 +876,10 @@ pub struct KeyCounterSettings { pub align_mode: KeyCounterAlignMode, #[serde(default)] pub fill: KeyCounterColor, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fill_idle_gradient: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fill_active_gradient: Option, #[serde(default = "default_stroke_color")] pub stroke: KeyCounterColor, #[serde(default = "default_gap")] @@ -790,6 +917,8 @@ impl Default for KeyCounterSettings { align: KeyCounterAlign::Bottom, align_mode: KeyCounterAlignMode::Center, fill: KeyCounterColor::default(), + fill_idle_gradient: None, + fill_active_gradient: None, stroke: default_stroke_color(), gap: default_gap(), font_size: default_counter_font_size(), @@ -812,6 +941,11 @@ impl KeyCounterSettings { /// This keeps existing user customizations intact, while fixing the old default /// active fill/stroke values (black text / outlined) that diverged from the renderer. pub fn migrate_legacy_defaults(&mut self) -> bool { + if self.fill_idle_gradient.is_some() || self.fill_active_gradient.is_some() { + self.normalize(); + return false; + } + // 당시 직렬화되던 스냅샷 값 고정 — 현재 기본값 함수와 결합 금지 let looks_like_legacy_default = self.fill.idle == "#FFFFFF" && self.fill.active == "#000000" @@ -870,6 +1004,175 @@ impl KeyCounterSettings { self.normalize(); false } + + pub(crate) fn canonicalize_gradient_pairs(&mut self) -> (bool, bool) { + let mut changed = false; + let mut pair_repaired = false; + + let (idle_changed, idle_pair_repaired) = + canonicalize_counter_gradient_pair(&mut self.fill.idle, &mut self.fill_idle_gradient); + changed |= idle_changed; + pair_repaired |= idle_pair_repaired; + + let (active_changed, active_pair_repaired) = canonicalize_counter_gradient_pair( + &mut self.fill.active, + &mut self.fill_active_gradient, + ); + changed |= active_changed; + pair_repaired |= active_pair_repaired; + + (changed, pair_repaired) + } +} + +impl KeyPosition { + pub(crate) fn canonicalize_gradient_pairs(&mut self) -> (bool, bool) { + let mut changed = false; + let mut pair_repaired = false; + + for (base, gradient) in [ + (&mut self.background_color, &mut self.background_gradient), + ( + &mut self.active_background_color, + &mut self.active_background_gradient, + ), + (&mut self.border_color, &mut self.border_gradient), + ( + &mut self.active_border_color, + &mut self.active_border_gradient, + ), + ] { + let (pair_changed, base_repaired) = canonicalize_optional_gradient_pair(base, gradient); + changed |= pair_changed; + pair_repaired |= base_repaired; + } + + let (counter_changed, counter_pair_repaired) = self.counter.canonicalize_gradient_pairs(); + changed |= counter_changed; + pair_repaired |= counter_pair_repaired; + + (changed, pair_repaired) + } +} + +fn canonicalize_optional_gradient_pair( + base: &mut Option, + gradient: &mut Option, +) -> (bool, bool) { + let Some(gradient) = gradient else { + return (false, false); + }; + + let mut changed = gradient.canonicalize(); + let representative = gradient + .stops + .first() + .expect("a deserialized gradient always has at least two stops") + .color + .clone(); + let pair_repaired = base.as_deref() != Some(representative.as_str()); + if pair_repaired { + *base = Some(representative); + changed = true; + } + (changed, pair_repaired) +} + +fn canonicalize_counter_gradient_pair( + base: &mut String, + gradient: &mut Option, +) -> (bool, bool) { + let Some(gradient) = gradient else { + return (false, false); + }; + + let mut changed = gradient.canonicalize(); + let representative = compact_canonical_rgba( + &gradient + .stops + .first() + .expect("a deserialized gradient always has at least two stops") + .color, + ); + let pair_repaired = *base != representative; + if pair_repaired { + *base = representative; + changed = true; + } + (changed, pair_repaired) +} + +fn compact_canonical_rgba(color: &str) -> String { + let trimmed = color.trim(); + if let Some(hex) = trimmed.strip_prefix('#') { + if matches!(hex.len(), 3 | 6 | 8) && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + let expanded; + let hex = if hex.len() == 3 { + expanded = hex + .chars() + .flat_map(|character| [character, character]) + .collect::(); + expanded.as_str() + } else { + hex + }; + let red = u8::from_str_radix(&hex[0..2], 16).expect("validated hex channel"); + let green = u8::from_str_radix(&hex[2..4], 16).expect("validated hex channel"); + let blue = u8::from_str_radix(&hex[4..6], 16).expect("validated hex channel"); + let alpha = if hex.len() == 8 { + f64::from(u8::from_str_radix(&hex[6..8], 16).expect("validated alpha channel")) + / 255.0 + } else { + 1.0 + }; + return format!("rgba({red},{green},{blue},{})", format_compact_alpha(alpha)); + } + } + + let functional = trimmed + .strip_prefix("rgba(") + .or_else(|| trimmed.strip_prefix("rgb(")); + if let Some(body) = functional { + if let Some(body) = body.strip_suffix(')') { + let channels = body.split(',').map(str::trim).collect::>(); + if matches!(channels.len(), 3 | 4) + && channels.iter().all(|channel| { + !channel.is_empty() + && channel + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'.') + }) + { + let parsed = channels + .iter() + .map(|channel| channel.parse::()) + .collect::, _>>(); + if let Ok(parsed) = parsed { + let alpha = parsed.get(3).copied().unwrap_or(1.0); + return format!( + "rgba({},{},{},{})", + parsed[0].round() as i64, + parsed[1].round() as i64, + parsed[2].round() as i64, + format_compact_alpha(alpha) + ); + } + } + } + } + + trimmed.to_string() +} + +fn format_compact_alpha(alpha: f64) -> String { + let rounded = (alpha.clamp(0.0, 1.0) * 10_000.0).round() / 10_000.0; + let formatted = format!("{rounded:.4}"); + let compact = formatted.trim_end_matches('0').trim_end_matches('.'); + if compact.is_empty() { + "0".to_string() + } else { + compact.to_string() + } } fn default_counter_animation_enabled() -> bool { @@ -1988,7 +2291,12 @@ pub struct SettingsPatch { #[cfg(test)] mod tests { - use super::{FadePosition, KeyPosition, NoteColor, NoteSettings, StatType}; + use super::{ + compact_canonical_rgba, FadePosition, GradientSpec, KeyCounterAlign, KeyCounterAlignMode, + KeyCounterColor, KeyCounterPlacement, KeyCounterSettings, KeyPosition, NoteColor, + NoteSettings, StatType, + }; + use serde::Deserialize; #[test] fn stat_type_wire_values_round_trip() { @@ -2108,4 +2416,259 @@ mod tests { assert_eq!(position.note_color, NoteColor::Solid("#FFFFFF".to_string())); assert_eq!(position.note_opacity, 90); } + + #[test] + fn gradient_spec_tolerates_legacy_shape_and_serializes_canonically() { + let gradient: GradientSpec = serde_json::from_value(serde_json::json!({ + "type": "linear", + "stops": [ + { "color": "c9", "pos": 1.4 }, + { "color": "c8", "pos": 0.8 }, + { "color": "c7", "pos": 0.7 }, + { "color": "c6", "pos": 0.6 }, + { "color": "c5", "pos": 0.5 }, + { "color": "c4", "pos": 0.4 }, + { "color": "c3", "pos": 0.3 }, + { "color": "c2", "pos": 0.2 }, + { "color": "c1", "pos": 0.1 }, + { "color": "c0", "pos": -0.2 } + ] + })) + .unwrap(); + + assert_eq!(gradient.angle, 90.0); + assert_eq!(gradient.stops.len(), 8); + assert_eq!(gradient.stops.first().unwrap().color, "c0"); + assert_eq!(gradient.stops.first().unwrap().pos, 0.0); + assert_eq!(gradient.stops.last().unwrap().color, "c7"); + + let canonical = serde_json::to_value(&gradient).unwrap(); + assert_eq!(canonical["angle"], 90.0); + assert_eq!(canonical["stops"].as_array().unwrap().len(), 8); + assert!(canonical.get("type").is_none()); + + let restored: GradientSpec = serde_json::from_value(canonical.clone()).unwrap(); + assert_eq!(serde_json::to_value(restored).unwrap(), canonical); + } + + #[test] + fn gradient_spec_rejects_fewer_than_two_stops() { + let error = serde_json::from_value::(serde_json::json!({ + "angle": 90, + "stops": [{ "color": "#FFFFFF", "pos": 0 }] + })) + .unwrap_err(); + + assert!(error.to_string().contains("at least two stops")); + } + + #[test] + fn gradient_spec_rejects_null_angle_but_preserves_stop_alpha_strings() { + let error = serde_json::from_value::(serde_json::json!({ + "angle": null, + "stops": [ + { "color": "rgba(1,2,3,0)", "pos": 0 }, + { "color": "rgba(1,2,3,1)", "pos": 1 } + ] + })) + .unwrap_err(); + assert!(error.to_string().contains("invalid gradient angle")); + + let gradient: GradientSpec = serde_json::from_value(serde_json::json!({ + "stops": [ + { "color": "rgba(1,2,3,0)", "pos": 0 }, + { "color": "rgba(1,2,3,0.5)", "pos": 0.5 }, + { "color": "rgba(1,2,3,1)", "pos": 1 } + ] + })) + .unwrap(); + assert_eq!( + gradient + .stops + .iter() + .map(|stop| stop.color.as_str()) + .collect::>(), + ["rgba(1,2,3,0)", "rgba(1,2,3,0.5)", "rgba(1,2,3,1)"] + ); + } + + #[test] + fn counter_gradient_escape_differs_from_every_legacy_snapshot_literal() { + let legacy_literals = [ + "#FFFFFF", + "#000000", + "rgba(121, 121, 121, 0.9)", + "transparent", + ]; + let visual_pairs = [ + ("#FFFFFF", "rgba(255,255,255,1)"), + ("#000000", "rgba(0,0,0,1)"), + ("rgba(121, 121, 121, 0.9)", "rgba(121,121,121,0.9)"), + ]; + + for (input, expected) in visual_pairs { + let escaped = compact_canonical_rgba(input); + assert_eq!(escaped, expected); + assert!(legacy_literals.iter().all(|literal| escaped != *literal)); + } + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct PreFeatureKeyPosition { + background_color: Option, + counter: PreFeatureCounterSettings, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct PreFeatureCounterSettings { + fill: KeyCounterColor, + stroke: KeyCounterColor, + placement: KeyCounterPlacement, + align: KeyCounterAlign, + align_mode: KeyCounterAlignMode, + gap: u32, + font_size: u32, + font_weight: u32, + font_family: Option, + font_italic: bool, + font_underline: bool, + font_strikethrough: bool, + } + + impl PreFeatureCounterSettings { + fn matches_legacy_migration_snapshot(&self) -> bool { + let shared = matches!(self.placement, KeyCounterPlacement::Inside) + && matches!(self.align, KeyCounterAlign::Top) + && matches!(self.align_mode, KeyCounterAlignMode::Center) + && self.gap == 6 + && self.font_size == 16 + && self.font_family.is_none() + && !self.font_italic + && !self.font_underline + && !self.font_strikethrough; + let oldest = self.fill.idle == "#FFFFFF" + && self.fill.active == "#000000" + && self.stroke.idle == "#000000" + && self.stroke.active == "#FFFFFF" + && self.font_weight == 400; + let previous = self.fill.idle == "rgba(121, 121, 121, 0.9)" + && self.fill.active == "#FFFFFF" + && self.stroke.idle == "transparent" + && self.stroke.active == "transparent" + && self.font_weight == 700; + shared && (oldest || previous) + } + } + + #[test] + fn pre_feature_shadow_downgrade_ignores_gradients_without_triggering_migration() { + for (legacy_fill, first_stop, expected_escape) in [ + ("#FFFFFF", "#FFFFFF", "rgba(255,255,255,1)"), + ( + "rgba(121, 121, 121, 0.9)", + "rgba(121, 121, 121, 0.9)", + "rgba(121,121,121,0.9)", + ), + ] { + let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "backgroundColor": "#102030", + "backgroundGradient": { + "angle": 90, + "stops": [ + { "color": "#102030", "pos": 0 }, + { "color": "#405060", "pos": 1 } + ] + }, + "counter": { + "enabled": true, + "placement": "inside", + "align": "top", + "alignMode": "center", + "fill": { + "idle": legacy_fill, + "active": if legacy_fill == "#FFFFFF" { "#000000" } else { "#FFFFFF" } + }, + "fillIdleGradient": { + "angle": 90, + "stops": [ + { "color": first_stop, "pos": 0 }, + { "color": "#654321", "pos": 1 } + ] + }, + "stroke": if legacy_fill == "#FFFFFF" { + serde_json::json!({ "idle": "#000000", "active": "#FFFFFF" }) + } else { + serde_json::json!({ "idle": "transparent", "active": "transparent" }) + }, + "gap": 6, + "fontSize": 16, + "fontWeight": if legacy_fill == "#FFFFFF" { 400 } else { 700 }, + "fontFamily": null, + "fontItalic": false, + "fontUnderline": false, + "fontStrikethrough": false + } + })) + .unwrap(); + + let (_, pair_repaired) = position.canonicalize_gradient_pairs(); + assert!(pair_repaired); + assert_eq!(position.counter.fill.idle, expected_escape); + assert!(!position.counter.migrate_legacy_defaults()); + + let serialized = serde_json::to_value(&position).unwrap(); + let shadow: PreFeatureKeyPosition = serde_json::from_value(serialized).unwrap(); + assert_eq!(shadow.background_color.as_deref(), Some("#102030")); + assert_eq!(shadow.counter.fill.idle, expected_escape); + assert!(!shadow.counter.matches_legacy_migration_snapshot()); + } + } + + #[test] + fn counter_migration_without_gradients_preserves_both_legacy_upgrade_branches() { + for snapshot in [ + serde_json::json!({ + "placement": "inside", + "align": "top", + "alignMode": "center", + "fill": { "idle": "#FFFFFF", "active": "#000000" }, + "stroke": { "idle": "#000000", "active": "#FFFFFF" }, + "gap": 6, + "fontSize": 16, + "fontWeight": 400, + "fontFamily": null, + "fontItalic": false, + "fontUnderline": false, + "fontStrikethrough": false + }), + serde_json::json!({ + "placement": "inside", + "align": "top", + "alignMode": "center", + "fill": { + "idle": "rgba(121, 121, 121, 0.9)", + "active": "#FFFFFF" + }, + "stroke": { "idle": "transparent", "active": "transparent" }, + "gap": 6, + "fontSize": 16, + "fontWeight": 700, + "fontFamily": null, + "fontItalic": false, + "fontUnderline": false, + "fontStrikethrough": false + }), + ] { + let mut counter: KeyCounterSettings = serde_json::from_value(snapshot).unwrap(); + + assert!(counter.migrate_legacy_defaults()); + assert_eq!(counter, KeyCounterSettings::default()); + } + } } diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index 7420ff22..b6d9b6c2 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -18,8 +18,8 @@ use crate::{ defaults::{default_keys, default_positions}, models::{ AppStoreData, CounterAnimationPreset, CustomCss, CustomFont, CustomJs, CustomTab, FontType, - GraphPosition, GraphPositions, GraphStatType, GraphType, GridSettings, JsPlugin, - KeyCounters, KeyMappings, KeyPosition, KeyPositions, KnobPosition, KnobPositions, + GradientSpec, GraphPosition, GraphPositions, GraphStatType, GraphType, GridSettings, + JsPlugin, KeyCounters, KeyMappings, KeyPosition, KeyPositions, KnobPosition, KnobPositions, LayerGroupDef, LayerGroups, NoteSettings, OverlayBounds, ShortcutsState, SoundLibraryEntry, StatPosition, StatPositions, StatType, TabCss, TabNoteSettings, }, @@ -97,12 +97,18 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { value.get("keyPositions"), ); needs_persist |= layout_repaired; + let (gradient_changed, gradient_pair_repaired) = + canonicalize_gradient_pairs(&mut data); + needs_persist |= gradient_changed; needs_persist |= key_position_lengths_mismatch(&data.keys, &data.key_positions); needs_persist |= !has_valid_selected_key_type(&data); ( normalize_state(data), needs_persist, - layout_repaired || semantic_repaired || editor_revision_repaired, + layout_repaired + || semantic_repaired + || editor_revision_repaired + || gradient_pair_repaired, ) } Err(err) => { @@ -836,6 +842,37 @@ pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { data } +pub(crate) fn canonicalize_gradient_pairs(data: &mut AppStoreData) -> (bool, bool) { + let mut changed = false; + let mut pair_repaired = false; + + for position in data.key_positions.values_mut().flatten() { + let (position_changed, position_pair_repaired) = position.canonicalize_gradient_pairs(); + changed |= position_changed; + pair_repaired |= position_pair_repaired; + } + for stat in data.stat_positions.values_mut().flatten() { + let (position_changed, position_pair_repaired) = + stat.position.canonicalize_gradient_pairs(); + changed |= position_changed; + pair_repaired |= position_pair_repaired; + } + for graph in data.graph_positions.values_mut().flatten() { + let (position_changed, position_pair_repaired) = + graph.position.canonicalize_gradient_pairs(); + changed |= position_changed; + pair_repaired |= position_pair_repaired; + } + for knob in data.knob_positions.values_mut().flatten() { + let (position_changed, position_pair_repaired) = + knob.position.canonicalize_gradient_pairs(); + changed |= position_changed; + pair_repaired |= position_pair_repaired; + } + + (changed, pair_repaired) +} + fn repair_editor_revision(data: &mut AppStoreData) -> bool { if data.editor_revision <= super::editor::MAX_SAFE_EDITOR_REVISION { return false; @@ -1056,6 +1093,7 @@ fn repair_legacy_state(value: Value) -> AppStoreData { source_keys.as_ref(), source_key_positions.as_ref(), ); + canonicalize_gradient_pairs(&mut data); normalize_state(data) } @@ -1383,15 +1421,23 @@ where continue; } - if !has_valid_identity(entry) { + let entry_name = format!("{field}.{mode}[{index}]"); + let mut candidate = entry.clone(); + recover_invalid_counter_gradient_children(&entry_name, &mut candidate); + if serde_json::from_value::(candidate.clone()).is_ok() { + recovered_entries.push(candidate); + continue; + } + + if !has_valid_identity(&candidate) { log::warn!( "[Store] Removing invalid {field} entry '{mode}[{index}]' with a damaged identity during recovery" ); continue; } - let entry_name = format!("{field}.{mode}[{index}]"); - let Some(partial) = recover_object_fields::(&entry_name, entry) else { + let Some(partial) = recover_object_fields::(&entry_name, &candidate) + else { log::warn!( "[Store] Removing invalid {field} entry '{mode}[{index}]' during recovery" ); @@ -1460,7 +1506,14 @@ fn recover_key_position_entries(field: &str, value: &Value) -> Option { Ok(_) => recovered_entries.push(entry.clone()), Err(err) => { let entry_name = format!("{field}.{mode}[{index}]"); - let recovered = recover_object_fields::(&entry_name, entry) + let mut candidate = entry.clone(); + recover_invalid_counter_gradient_children(&entry_name, &mut candidate); + let recovered = if serde_json::from_value::(candidate.clone()) + .is_ok() + { + candidate + } else { + recover_object_fields::(&entry_name, &candidate) .filter(|candidate| { serde_json::from_value::(candidate.clone()).is_ok() }) @@ -1469,7 +1522,8 @@ fn recover_key_position_entries(field: &str, value: &Value) -> Option { "[Store] Replacing invalid {field} entry '{mode}[{index}]' with default during recovery: {err}" ); default_position.clone() - }); + }) + }; recovered_entries.push(recovered); } } @@ -1479,6 +1533,27 @@ fn recover_key_position_entries(field: &str, value: &Value) -> Option { Some(Value::Object(recovered_modes)) } +fn recover_invalid_counter_gradient_children(entry_name: &str, value: &mut Value) -> bool { + let Some(counter) = value.get_mut("counter").and_then(Value::as_object_mut) else { + return false; + }; + + let mut changed = false; + for field in ["fillIdleGradient", "fillActiveGradient"] { + let invalid = counter.get(field).is_some_and(|gradient| { + serde_json::from_value::>(gradient.clone()).is_err() + }); + if invalid { + log::warn!( + "[Store] Resetting invalid {entry_name}.counter.{field} to None during recovery" + ); + counter.remove(field); + changed = true; + } + } + changed +} + fn recover_position_entries(field: &str, value: &Value) -> Option where T: DeserializeOwned, @@ -1749,6 +1824,7 @@ mod tests { defaults::{default_keys, default_positions}, models::{ AppStoreData, CustomFont, CustomTab, FontType, GraphPosition, GraphStatType, GraphType, + KeyCounterAlign, KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, KeyPosition, KnobPosition, LayerGroupDef, OverlayBounds, SoundLibraryEntry, StatPosition, StatType, TabCss, TabNoteSettings, }, @@ -1826,6 +1902,158 @@ mod tests { loaded.data } + #[test] + fn legacy_store_without_gradient_fields_preserves_position_bytes() { + let path = std::env::temp_dir().join(format!( + "dmnote-no-gradient-byte-round-trip-{}.json", + uuid::Uuid::new_v4() + )); + let data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + let original_position = serde_json::to_vec_pretty(&data.key_positions["4key"][0]).unwrap(); + let original = serde_json::to_vec_pretty(&data).unwrap(); + assert!(!String::from_utf8_lossy(&original).contains("Gradient")); + std::fs::write(&path, &original).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let reserialized_position = + serde_json::to_vec_pretty(&loaded.data.key_positions["4key"][0]).unwrap(); + + assert!(!loaded.needs_persist); + assert!(!loaded.repaired); + assert_eq!(reserialized_position, original_position); + let _ = std::fs::remove_file(path); + } + + #[test] + fn noncanonical_gradient_store_repersist_and_reload_is_idempotent() { + let path = std::env::temp_dir().join(format!( + "dmnote-gradient-canonical-reload-{}.json", + uuid::Uuid::new_v4() + )); + let data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + let mut raw = serde_json::to_value(data).unwrap(); + let position = &mut raw["keyPositions"]["4key"][0]; + position["backgroundColor"] = serde_json::json!("#BADBAD"); + position["backgroundGradient"] = serde_json::json!({ + "type": "linear", + "angle": 450, + "stops": [ + { "color": "rgba(90, 162, 247, 1)", "pos": 1.4 }, + { "color": "rgba(139, 92, 246, 1)", "pos": -0.2 } + ] + }); + position["counter"]["fill"]["idle"] = serde_json::json!("#FFFFFF"); + position["counter"]["fillIdleGradient"] = serde_json::json!({ + "stops": [ + { "color": "#FFFFFF", "pos": 0 }, + { "color": "#000000", "pos": 1 } + ] + }); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let position = &loaded.data.key_positions["4key"][0]; + assert!(loaded.needs_persist); + assert!(loaded.repaired); + assert_eq!( + position.background_color.as_deref(), + Some("rgba(139, 92, 246, 1)") + ); + assert_eq!(position.background_gradient.as_ref().unwrap().angle, 90.0); + assert_eq!( + position.background_gradient.as_ref().unwrap().stops[0].pos, + 0.0 + ); + assert_eq!(position.counter.fill.idle, "rgba(255,255,255,1)"); + + std::fs::write(&path, serde_json::to_vec_pretty(&loaded.data).unwrap()).unwrap(); + let reloaded = load_store_from_path(&path).unwrap(); + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + assert_eq!(reloaded.data, loaded.data); + let _ = std::fs::remove_file(path); + } + + #[test] + fn invalid_counter_gradient_children_recover_without_losing_counter_siblings() { + let path = std::env::temp_dir().join(format!( + "dmnote-counter-gradient-child-recovery-{}.json", + uuid::Uuid::new_v4() + )); + let mut key_position = default_positions()["4key"][0].clone(); + key_position.counter.enabled = false; + key_position.counter.placement = KeyCounterPlacement::Outside; + key_position.counter.align = KeyCounterAlign::Left; + key_position.counter.align_mode = KeyCounterAlignMode::Between; + key_position.counter.fill = KeyCounterColor { + idle: "#112233".to_string(), + active: "#445566".to_string(), + }; + key_position.counter.stroke = KeyCounterColor { + idle: "#778899".to_string(), + active: "#AABBCC".to_string(), + }; + key_position.counter.gap = 17; + key_position.counter.font_size = 33; + key_position.counter.font_weight = 600; + key_position.counter.font_family = Some("Recovery Font".to_string()); + key_position.counter.font_italic = true; + key_position.counter.font_underline = true; + key_position.counter.font_strikethrough = true; + key_position.counter.animation.enabled = true; + key_position.counter.animation.preset_id = Some("custom-recovery".to_string()); + key_position.counter.animation.bezier = [0.1, 0.2, 0.7, 0.8]; + key_position.counter.animation.scale = 1.25; + key_position.counter.animation.duration_ms = 777; + let expected_key_counter = key_position.counter.clone(); + + let mut stat_position = StatPosition { + stat_type: StatType::Kps, + position: key_position.clone(), + }; + stat_position.position.counter.fill.idle = "#ABCDEF".to_string(); + let expected_stat_counter = stat_position.position.counter.clone(); + + let mut data = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + data.key_positions.get_mut("4key").unwrap()[0] = key_position; + data.stat_positions + .insert("4key".to_string(), vec![stat_position]); + let mut raw = serde_json::to_value(data).unwrap(); + raw["keyPositions"]["4key"][0]["counter"]["fillIdleGradient"] = serde_json::json!({ + "angle": 90, + "stops": [{ "color": "#FFFFFF", "pos": 0 }] + }); + raw["statPositions"]["4key"][0]["counter"]["fillActiveGradient"] = serde_json::json!({ + "angle": 90, + "stops": [{ "color": "#000000", "pos": 1 }] + }); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let key_counter = &loaded.data.key_positions["4key"][0].counter; + let stat_counter = &loaded.data.stat_positions["4key"][0].position.counter; + + assert!(loaded.needs_persist); + assert!(loaded.repaired); + assert_eq!(key_counter, &expected_key_counter); + assert_eq!(stat_counter, &expected_stat_counter); + assert!(key_counter.fill_idle_gradient.is_none()); + assert!(stat_counter.fill_active_gradient.is_none()); + let _ = std::fs::remove_file(path); + } + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct HistoricalTauri13Store { diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index 89bff6cd..40a678a7 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -454,6 +454,11 @@ impl AppStore { let mut candidate = current.clone(); candidate.apply_patch(&request.changes); + let mut scratch = current_store.clone(); + candidate.apply_to_store(&mut scratch); + crate::state::migration::canonicalize_gradient_pairs(&mut scratch); + candidate = EditorDocumentV1::from_store(&scratch); + validate_paired_update( ¤t, &candidate, @@ -461,8 +466,6 @@ impl AppStore { request.changes.includes(EditorField::KeyPositions), )?; - let mut scratch = current_store.clone(); - candidate.apply_to_store(&mut scratch); scratch.editor_revision = current_store.editor_revision; validate_document_transition(¤t, &candidate, ¤t_store, &scratch)?; @@ -575,6 +578,7 @@ impl AppStore { let current = EditorDocumentV1::from_store(¤t_store); let mut scratch = current_store.clone(); let value = updater(&mut scratch)?; + crate::state::migration::canonicalize_gradient_pairs(&mut scratch); // editorRevision은 이 트랜잭션만 관리 scratch.editor_revision = current_store.editor_revision; @@ -2203,6 +2207,9 @@ fn sweep_unreferenced_asset_files( Ok(()) } +#[cfg(test)] +mod gradient_real_data_simulation; + #[cfg(test)] mod tests { use super::{ @@ -2380,6 +2387,135 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn editor_commit_gradient_repair_keeps_disk_document_and_event_identical() { + let dir = test_directory("editor-gradient-canonicalization-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut positions = store.editor_get().document.key_positions; + let position = &mut positions.get_mut("4key").unwrap()[0]; + position.background_color = Some("#BADBAD".to_string()); + position.background_gradient = Some( + serde_json::from_value(json!({ + "type": "linear", + "stops": [ + { "color": "rgba(90, 162, 247, 1)", "pos": 1.2 }, + { "color": "rgba(139, 92, 246, 1)", "pos": -0.1 } + ] + })) + .unwrap(), + ); + position.counter.fill.idle = "#FFFFFF".to_string(); + position.counter.fill_idle_gradient = Some( + serde_json::from_value(json!({ + "angle": 450, + "stops": [ + { "color": "#FFFFFF80", "pos": 0 }, + { "color": "rgba(0, 0, 0, 0)", "pos": 1 } + ] + })) + .unwrap(), + ); + let request = editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + key_positions: Some(positions), + ..EditorPatchV1::default() + }, + ); + + let change = store.commit_editor_document(request).unwrap(); + let committed_position = &change.document.key_positions["4key"][0]; + assert_eq!( + change.result.changed_fields, + vec![EditorField::KeyPositions] + ); + assert_eq!( + committed_position + .background_gradient + .as_ref() + .unwrap() + .angle, + 90.0 + ); + assert_eq!( + committed_position + .background_gradient + .as_ref() + .unwrap() + .stops[0] + .pos, + 0.0 + ); + assert_eq!( + committed_position.background_color.as_deref(), + Some("rgba(139, 92, 246, 1)") + ); + assert_eq!( + committed_position.counter.fill.idle, + "rgba(255,255,255,0.502)" + ); + + let event_positions = change + .event + .as_ref() + .unwrap() + .patch + .key_positions + .as_ref() + .unwrap(); + assert_eq!(event_positions, &change.document.key_positions); + assert_eq!( + EditorDocumentV1::from_store(&store.snapshot()), + change.document + ); + + let committed_document = change.document; + store.flush_and_shutdown().unwrap(); + drop(store); + let reloaded = + crate::state::migration::load_store_from_path(&dir.join("store.json")).unwrap(); + assert!(!reloaded.needs_persist); + assert_eq!( + EditorDocumentV1::from_store(&reloaded.data), + committed_document + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn invalid_gradient_preset_parse_failure_leaves_store_unchanged() { + let dir = test_directory("invalid-gradient-preset-atomicity-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.snapshot(); + let persist_count = store.writer.persist_count(); + + let parsed = serde_json::from_value::(json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": { + "4key": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "backgroundGradient": { + "angle": 90, + "stops": [{ "color": "#FFFFFF", "pos": 0 }] + } + }] + } + })); + + assert!(parsed.is_err()); + assert_eq!(store.snapshot(), before); + assert_eq!(store.writer.persist_count(), persist_count); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn strict_editor_noop_is_acknowledged_without_persist_or_event() { let dir = test_directory("strict-editor-noop-test"); diff --git a/src-tauri/src/state/store/gradient_real_data_simulation.rs b/src-tauri/src/state/store/gradient_real_data_simulation.rs new file mode 100644 index 00000000..2edf5fb2 --- /dev/null +++ b/src-tauri/src/state/store/gradient_real_data_simulation.rs @@ -0,0 +1,760 @@ +use std::{ + collections::BTreeSet, + fs, + path::{Path, PathBuf}, +}; + +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; + +use super::AppStore; +use crate::{ + commands::preset::{ + load::read_preset_file_for_simulation, save::write_preset_file_for_simulation, PresetFile, + }, + defaults::{default_keys, default_positions}, + models::{AppStoreData, EditorCommitOrigin, EditorField, GradientSpec, KeyPosition}, + state::migration::{canonicalize_gradient_pairs, load_store_from_path, normalize_state}, +}; + +const GRADIENT_FIELDS: [&str; 6] = [ + "backgroundGradient", + "activeBackgroundGradient", + "borderGradient", + "activeBorderGradient", + "fillIdleGradient", + "fillActiveGradient", +]; + +struct RealFixture { + source_path: PathBuf, + bytes: Vec, + digest: Vec, +} + +impl RealFixture { + fn from_env(simulation: u8) -> Option { + let Some(source_path) = std::env::var_os("DMNOTE_SIM_STORE_PATH").map(PathBuf::from) else { + println!( + "SIMULATION {simulation} SKIP: DMNOTE_SIM_STORE_PATH environment variable is unset" + ); + return None; + }; + let bytes = fs::read(&source_path).unwrap_or_else(|error| { + panic!("failed to read DMNOTE_SIM_STORE_PATH for simulation {simulation}: {error}") + }); + assert!( + !bytes.is_empty(), + "DMNOTE_SIM_STORE_PATH must point to a non-empty store" + ); + let digest = Sha256::digest(&bytes).to_vec(); + Some(Self { + source_path, + bytes, + digest, + }) + } + + fn verify_unchanged(&self) { + let current = fs::read(&self.source_path) + .expect("the source fixture must remain readable after the simulation"); + assert_eq!( + Sha256::digest(¤t).to_vec(), + self.digest, + "the source fixture changed during the simulation" + ); + } +} + +struct SimulationDir { + path: PathBuf, +} + +impl SimulationDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "dmnote-gradient-real-data-{label}-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&path).expect("simulation temp directory must be creatable"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for SimulationDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[derive(Clone)] +struct PositionLocation { + mode: String, + index: usize, +} + +fn write_store_copy(directory: &SimulationDir, name: &str, bytes: &[u8]) -> PathBuf { + let path = directory.path().join(name); + fs::write(&path, bytes).expect("simulation store copy must be writable"); + path +} + +fn first_position_location(value: &Value) -> PositionLocation { + let modes = value + .get("keyPositions") + .and_then(Value::as_object) + .expect("real store must contain a keyPositions object"); + for (mode, positions) in modes { + if positions + .as_array() + .is_some_and(|positions| !positions.is_empty()) + { + return PositionLocation { + mode: mode.clone(), + index: 0, + }; + } + } + panic!("real store must contain at least one key position"); +} + +fn position_object_mut<'a>( + value: &'a mut Value, + location: &PositionLocation, +) -> &'a mut Map { + value + .get_mut("keyPositions") + .and_then(Value::as_object_mut) + .and_then(|modes| modes.get_mut(&location.mode)) + .and_then(Value::as_array_mut) + .and_then(|positions| positions.get_mut(location.index)) + .and_then(Value::as_object_mut) + .expect("selected key position must remain an object") +} + +fn position_from_store<'a>(data: &'a AppStoreData, location: &PositionLocation) -> &'a KeyPosition { + data.key_positions + .get(&location.mode) + .and_then(|positions| positions.get(location.index)) + .expect("selected key position must survive the simulation") +} + +fn serialized_position(data: &AppStoreData, location: &PositionLocation) -> Value { + serde_json::to_value(position_from_store(data, location)) + .expect("key position must remain serializable") +} + +fn contains_gradient_fields(value: &Value) -> bool { + match value { + Value::Array(values) => values.iter().any(contains_gradient_fields), + Value::Object(fields) => fields.iter().any(|(name, value)| { + GRADIENT_FIELDS.contains(&name.as_str()) || contains_gradient_fields(value) + }), + _ => false, + } +} + +fn collect_counter_colors(value: &Value, colors: &mut Vec) { + match value { + Value::Array(values) => { + for value in values { + collect_counter_colors(value, colors); + } + } + Value::Object(fields) => { + if let Some(counter) = fields.get("counter").and_then(Value::as_object) { + if let Some(fill) = counter.get("fill") { + colors.push(fill.clone()); + } + if let Some(stroke) = counter.get("stroke") { + colors.push(stroke.clone()); + } + } + for value in fields.values() { + collect_counter_colors(value, colors); + } + } + _ => {} + } +} + +fn counter_colors(value: &Value) -> Vec { + let mut colors = Vec::new(); + for field in [ + "keyPositions", + "statPositions", + "graphPositions", + "knobPositions", + ] { + if let Some(collection) = value.get(field) { + collect_counter_colors(collection, &mut colors); + } + } + colors +} + +fn collect_changed_paths(left: &Value, right: &Value, path: &str, changed: &mut BTreeSet) { + match (left, right) { + (Value::Object(left), Value::Object(right)) => { + for field in left.keys().chain(right.keys()) { + let next_path = if path.is_empty() { + field.clone() + } else { + format!("{path}.{field}") + }; + match (left.get(field), right.get(field)) { + (Some(left), Some(right)) => { + collect_changed_paths(left, right, &next_path, changed); + } + _ => { + changed.insert(next_path); + } + } + } + } + (Value::Array(left), Value::Array(right)) => { + if left.len() != right.len() { + changed.insert(format!("{path}.")); + } + for (left, right) in left.iter().zip(right) { + collect_changed_paths(left, right, path, changed); + } + } + _ if left != right => { + changed.insert(path.to_string()); + } + _ => {} + } +} + +fn changed_key_position_fields(left: &Value, right: &Value) -> BTreeSet { + let mut changed = BTreeSet::new(); + let Some(left_modes) = left.as_object() else { + changed.insert("".to_string()); + return changed; + }; + let Some(right_modes) = right.as_object() else { + changed.insert("".to_string()); + return changed; + }; + for mode in left_modes.keys().chain(right_modes.keys()) { + match (left_modes.get(mode), right_modes.get(mode)) { + (Some(Value::Array(left)), Some(Value::Array(right))) => { + if left.len() != right.len() { + changed.insert("".to_string()); + } + for (left, right) in left.iter().zip(right) { + collect_changed_paths(left, right, "", &mut changed); + } + } + _ => { + changed.insert("".to_string()); + } + } + } + changed +} + +fn editor_preset_from_store(data: &AppStoreData) -> PresetFile { + PresetFile { + keys: Some(data.keys.clone()), + key_positions: Some(data.key_positions.clone()), + stat_positions: Some(data.stat_positions.clone()), + graph_positions: Some(data.graph_positions.clone()), + knob_positions: Some(data.knob_positions.clone()), + custom_tabs: Some(data.custom_tabs.clone()), + selected_key_type: Some(data.selected_key_type.clone()), + layer_groups: Some(data.layer_groups.clone()), + ..PresetFile::default() + } +} + +fn import_editor_preset(directory: &Path, preset: PresetFile) -> AppStoreData { + fs::create_dir_all(directory).expect("preset import directory must be creatable"); + let seed = normalize_state(AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }); + fs::write( + directory.join("store.json"), + serde_json::to_vec_pretty(&seed).expect("seed store must be serializable"), + ) + .expect("seed store must be writable"); + + let store = + AppStore::initialize_in_dir(directory).expect("preset import store must initialize"); + let current = store.snapshot(); + let keys = preset.keys.unwrap_or_else(|| current.keys.clone()); + let key_positions = preset + .key_positions + .unwrap_or_else(|| current.key_positions.clone()); + let stat_positions = preset + .stat_positions + .unwrap_or_else(|| current.stat_positions.clone()); + let graph_positions = preset + .graph_positions + .unwrap_or_else(|| current.graph_positions.clone()); + let knob_positions = preset + .knob_positions + .unwrap_or_else(|| current.knob_positions.clone()); + let layer_groups = preset + .layer_groups + .unwrap_or_else(|| current.layer_groups.clone()); + let custom_tabs = preset + .custom_tabs + .unwrap_or_else(|| current.custom_tabs.clone()); + let selected_key_type = preset + .selected_key_type + .unwrap_or_else(|| current.selected_key_type.clone()); + + store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("gradient_simulation_preset_import".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + move |data| { + data.keys = keys; + data.key_positions = key_positions; + data.stat_positions = stat_positions; + data.graph_positions = graph_positions; + data.knob_positions = knob_positions; + data.layer_groups = layer_groups; + data.custom_tabs = custom_tabs; + data.selected_key_type = selected_key_type; + Ok(()) + }, + ) + .expect("preset editor collections must import atomically"); + + let committed = store.snapshot(); + store + .flush_and_shutdown() + .expect("imported preset must flush"); + drop(store); + + let reloaded = load_store_from_path(&directory.join("store.json")) + .expect("imported preset store must reload"); + assert!(!reloaded.repaired); + assert_eq!(reloaded.data.editor_revision, committed.editor_revision); + reloaded.data +} + +fn set_damage_control_values(position: &mut Map) { + position.insert("backgroundColor".to_string(), json!("#101010")); + position.insert("activeBackgroundColor".to_string(), json!("#202020")); + position.insert("borderColor".to_string(), json!("#303030")); + position.insert("activeBorderColor".to_string(), json!("#404040")); + + let counter = position + .get_mut("counter") + .and_then(Value::as_object_mut) + .expect("serialized key position must contain a counter object"); + let fill = counter + .get_mut("fill") + .and_then(Value::as_object_mut) + .expect("serialized counter must contain fill colors"); + fill.insert("idle".to_string(), json!("#505050")); + fill.insert("active".to_string(), json!("#606060")); + let stroke = counter + .get_mut("stroke") + .and_then(Value::as_object_mut) + .expect("serialized counter must contain stroke colors"); + stroke.insert("idle".to_string(), json!("#707070")); + stroke.insert("active".to_string(), json!("#808080")); +} + +#[test] +fn simulation_1_real_legacy_store_load_is_gradient_neutral() { + let Some(fixture) = RealFixture::from_env(1) else { + return; + }; + let directory = SimulationDir::new("legacy-load"); + let path = write_store_copy(&directory, "store.json", &fixture.bytes); + + let directly_parsed: AppStoreData = serde_json::from_slice(&fixture.bytes) + .expect("real store must deserialize without collection recovery"); + let mut gradient_probe = directly_parsed.clone(); + let (gradient_changed, gradient_pair_repaired) = + canonicalize_gradient_pairs(&mut gradient_probe); + assert!(!gradient_changed); + assert!(!gradient_pair_repaired); + + let loaded = load_store_from_path(&path).expect("real store must load through migration path"); + assert!(!loaded.repaired); + fixture.verify_unchanged(); + + println!( + "SIMULATION 1 PASS: directParse=true recover=false gradientNeedsPersist=false gradientRepaired=false aggregateNeedsPersist={} aggregateRepaired=false", + loaded.needs_persist + ); +} + +#[test] +fn simulation_2_real_solid_store_round_trip_is_lossless() { + let Some(fixture) = RealFixture::from_env(2) else { + return; + }; + let directory = SimulationDir::new("solid-round-trip"); + let path = write_store_copy(&directory, "store.json", &fixture.bytes); + let source: Value = + serde_json::from_slice(&fixture.bytes).expect("real store JSON must be readable"); + assert!(!contains_gradient_fields(&source)); + + let loaded = load_store_from_path(&path).expect("solid real store must load"); + let round_trip = + serde_json::to_value(&loaded.data).expect("loaded real store must reserialize"); + let keys_equal = source.get("keys") == round_trip.get("keys"); + let positions_equal = source.get("keyPositions") == round_trip.get("keyPositions"); + let counter_colors_equal = counter_colors(&source) == counter_colors(&round_trip); + let gradients_absent = !contains_gradient_fields(&round_trip); + let changed_fields = changed_key_position_fields( + source.get("keyPositions").unwrap(), + round_trip.get("keyPositions").unwrap(), + ); + + let keys_byte_equal = serde_json::to_vec(source.get("keys").unwrap()).unwrap() + == serde_json::to_vec(round_trip.get("keys").unwrap()).unwrap(); + let positions_byte_equal = serde_json::to_vec(source.get("keyPositions").unwrap()).unwrap() + == serde_json::to_vec(round_trip.get("keyPositions").unwrap()).unwrap(); + let full_store_byte_equal = serde_json::to_vec_pretty(&loaded.data) + .expect("loaded store must serialize") + == fixture.bytes; + fixture.verify_unchanged(); + + println!( + "SIMULATION 2 RESULT: keysEqual={keys_equal} keyPositionsEqual={positions_equal} counterColorsEqual={counter_colors_equal} gradientsAbsent={gradients_absent} keysBytes={keys_byte_equal} keyPositionsBytes={positions_byte_equal} fullStoreBytes={full_store_byte_equal} changedFields={changed_fields:?}" + ); + assert!(keys_equal, "keys changed during solid round trip"); + assert!( + positions_equal, + "keyPositions changed during solid round trip: {changed_fields:?}" + ); + assert!( + counter_colors_equal, + "counter colors changed during solid round trip" + ); + assert!(gradients_absent, "gradient siblings were generated"); + assert!(keys_byte_equal); + assert!(positions_byte_equal); +} + +#[test] +fn simulation_3_gradient_store_and_preset_chain_stays_canonical() { + let Some(fixture) = RealFixture::from_env(3) else { + return; + }; + let directory = SimulationDir::new("gradient-chain"); + let mut raw: Value = + serde_json::from_slice(&fixture.bytes).expect("real store JSON must be readable"); + let location = first_position_location(&raw); + let position = position_object_mut(&mut raw, &location); + position.insert("backgroundColor".to_string(), json!("#BADBAD")); + position.insert( + "backgroundGradient".to_string(), + json!({ + "type": "linear", + "angle": 450, + "stops": [ + { "color": "rgba(90, 162, 247, 1)", "pos": 1.4 }, + { "color": "rgba(139, 92, 246, 1)", "pos": -0.2 } + ] + }), + ); + let counter = position + .get_mut("counter") + .and_then(Value::as_object_mut) + .expect("real key position must contain counter settings"); + counter + .get_mut("fill") + .and_then(Value::as_object_mut) + .expect("real counter must contain fill colors") + .insert("idle".to_string(), json!("#BADBAD")); + counter.insert( + "fillIdleGradient".to_string(), + json!({ + "angle": -90, + "stops": [ + { "color": "#000000", "pos": 1.5 }, + { "color": "#FFFFFF80", "pos": -0.5 } + ] + }), + ); + + let injected_path = write_store_copy( + &directory, + "store-injected.json", + &serde_json::to_vec_pretty(&raw).unwrap(), + ); + let loaded = load_store_from_path(&injected_path) + .expect("noncanonical gradient store must load and repair"); + assert!(loaded.needs_persist); + assert!(loaded.repaired); + let canonical_position = position_from_store(&loaded.data, &location); + let background = canonical_position + .background_gradient + .as_ref() + .expect("background gradient must survive"); + assert_eq!(background.angle, 90.0); + assert_eq!(background.stops[0].pos, 0.0); + assert_eq!(background.stops[1].pos, 1.0); + assert_eq!(background.stops[0].color, "rgba(139, 92, 246, 1)"); + assert_eq!( + canonical_position.background_color.as_deref(), + Some("rgba(139, 92, 246, 1)") + ); + let counter_gradient = canonical_position + .counter + .fill_idle_gradient + .as_ref() + .expect("counter gradient must survive"); + assert_eq!(counter_gradient.angle, 270.0); + assert_eq!(counter_gradient.stops[0].pos, 0.0); + assert_eq!(counter_gradient.stops[1].pos, 1.0); + assert_eq!( + canonical_position.counter.fill.idle, + "rgba(255,255,255,0.502)" + ); + + let canonical_path = write_store_copy( + &directory, + "store-canonical.json", + &serde_json::to_vec_pretty(&loaded.data).unwrap(), + ); + let reloaded = load_store_from_path(&canonical_path) + .expect("canonical gradient store must reload idempotently"); + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + assert!(reloaded.data == loaded.data); + + let preset_path = directory.path().join("preset.json"); + let preset = editor_preset_from_store(&reloaded.data); + write_preset_file_for_simulation(&preset_path, &preset) + .expect("canonical store must export as a preset"); + let imported_preset = read_preset_file_for_simulation(&preset_path) + .expect("exported preset must pass the import parser"); + let imported_store = import_editor_preset(&directory.path().join("imported"), imported_preset); + assert!(imported_store.keys == reloaded.data.keys); + assert!(imported_store.key_positions == reloaded.data.key_positions); + assert!(imported_store.stat_positions == reloaded.data.stat_positions); + assert!(imported_store.graph_positions == reloaded.data.graph_positions); + assert!(imported_store.knob_positions == reloaded.data.knob_positions); + fixture.verify_unchanged(); + + println!( + "SIMULATION 3 PASS: loadRepair=true reloadIdempotent=true presetExportImport=true angleAndStopsCanonical=true" + ); +} + +#[test] +fn simulation_4_damaged_gradient_fields_are_isolated() { + let Some(fixture) = RealFixture::from_env(4) else { + return; + }; + let directory = SimulationDir::new("damaged-fields"); + let source_path = write_store_copy(&directory, "source.json", &fixture.bytes); + let source = load_store_from_path(&source_path).expect("real store baseline must load"); + assert!(!source.repaired); + let baseline_path = write_store_copy( + &directory, + "baseline.json", + &serde_json::to_vec_pretty(&source.data).unwrap(), + ); + let baseline = load_store_from_path(&baseline_path) + .expect("persisted real store baseline must reload idempotently"); + assert!(!baseline.needs_persist); + assert!(!baseline.repaired); + let baseline_value = serde_json::to_value(&baseline.data).unwrap(); + let location = first_position_location(&baseline_value); + + for (label, target_field, damage) in [ + ( + "wrong-type", + "backgroundGradient", + json!(["not", "an", "object"]), + ), + ( + "one-stop", + "fillIdleGradient", + json!({ + "angle": 90, + "stops": [{ "color": "#FFFFFF", "pos": 0 }] + }), + ), + ( + "string-angle", + "borderGradient", + json!({ + "angle": "90", + "stops": [ + { "color": "#111111", "pos": 0 }, + { "color": "#222222", "pos": 1 } + ] + }), + ), + ] { + let mut damaged = baseline_value.clone(); + let position = position_object_mut(&mut damaged, &location); + set_damage_control_values(position); + let expected = Value::Object(position.clone()); + if target_field == "fillIdleGradient" { + position + .get_mut("counter") + .and_then(Value::as_object_mut) + .unwrap() + .insert(target_field.to_string(), damage); + } else { + position.insert(target_field.to_string(), damage); + } + + let damaged_path = write_store_copy( + &directory, + &format!("damaged-{label}.json"), + &serde_json::to_vec_pretty(&damaged).unwrap(), + ); + let loaded = load_store_from_path(&damaged_path) + .unwrap_or_else(|error| panic!("{label} gradient damage must recover: {error}")); + assert!(loaded.needs_persist); + assert!(loaded.repaired); + assert!( + serialized_position(&loaded.data, &location) == expected, + "{label} damage changed fields outside its target" + ); + + let canonical_path = write_store_copy( + &directory, + &format!("canonical-{label}.json"), + &serde_json::to_vec_pretty(&loaded.data).unwrap(), + ); + let reloaded = load_store_from_path(&canonical_path).unwrap(); + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + println!("SIMULATION 4 CASE {label} PASS: action=drop siblingsPreserved=true"); + } + + let mut damaged = baseline_value; + let position = position_object_mut(&mut damaged, &location); + set_damage_control_values(position); + position.insert("activeBorderColor".to_string(), json!("#111111")); + let input_gradient = json!({ + "angle": 90, + "stops": [ + { "color": "#222222", "pos": 1.4 }, + { "color": "#111111", "pos": -0.4 } + ] + }); + position.insert("activeBorderGradient".to_string(), input_gradient.clone()); + let mut expected = Value::Object(position.clone()); + let canonical_gradient: GradientSpec = serde_json::from_value(input_gradient).unwrap(); + expected.as_object_mut().unwrap().insert( + "activeBorderGradient".to_string(), + serde_json::to_value(canonical_gradient).unwrap(), + ); + + let damaged_path = write_store_copy( + &directory, + "damaged-out-of-range.json", + &serde_json::to_vec_pretty(&damaged).unwrap(), + ); + let loaded = load_store_from_path(&damaged_path).expect("out-of-range stops must canonicalize"); + assert!(loaded.needs_persist); + assert!(!loaded.repaired); + assert!( + serialized_position(&loaded.data, &location) == expected, + "out-of-range repair changed fields outside its pair" + ); + let gradient = position_from_store(&loaded.data, &location) + .active_border_gradient + .as_ref() + .unwrap(); + assert_eq!(gradient.stops[0].pos, 0.0); + assert_eq!(gradient.stops[1].pos, 1.0); + + let canonical_path = write_store_copy( + &directory, + "canonical-out-of-range.json", + &serde_json::to_vec_pretty(&loaded.data).unwrap(), + ); + let reloaded = load_store_from_path(&canonical_path).unwrap(); + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + fixture.verify_unchanged(); + + println!("SIMULATION 4 CASE out-of-range PASS: action=canonicalize siblingsPreserved=true"); + println!("SIMULATION 4 PASS: damageCases=4 isolated=true reloadIdempotent=true"); +} + +#[test] +fn simulation_5_inline_legacy_preset_imports_without_gradients() { + let Some(fixture) = RealFixture::from_env(5) else { + return; + }; + const LEGACY_PRESET: &str = r##"{ + "keys": { + "4key": ["KeyQ"], + "5key": ["KeyW"], + "6key": ["KeyE"], + "8key": ["KeyR"] + }, + "keyPositions": { + "4key": [{ + "dx": 12, + "dy": 34, + "width": 58, + "count": 7, + "backgroundColor": "#123456", + "activeBackgroundColor": "#234567", + "borderColor": "#345678", + "activeBorderColor": "#456789", + "counter": { + "fill": { "idle": "#ABCDEF", "active": "#FEDCBA" }, + "stroke": { "idle": "#111111", "active": "#222222" } + } + }], + "5key": [{ "dx": 0, "dy": 0, "width": 60, "count": 0 }], + "6key": [{ "dx": 0, "dy": 0, "width": 60, "count": 0 }], + "8key": [{ "dx": 0, "dy": 0, "width": 60, "count": 0 }] + }, + "selectedKeyType": "4key" + }"##; + let legacy_value: Value = serde_json::from_str(LEGACY_PRESET).unwrap(); + assert!(!contains_gradient_fields(&legacy_value)); + + let directory = SimulationDir::new("legacy-preset"); + let preset_path = directory.path().join("legacy-preset.json"); + fs::write(&preset_path, LEGACY_PRESET).unwrap(); + let preset = read_preset_file_for_simulation(&preset_path) + .expect("gradient-free legacy preset must pass the import parser"); + let imported = import_editor_preset(&directory.path().join("imported"), preset); + let position = &imported.key_positions["4key"][0]; + assert_eq!(imported.keys["4key"], vec!["KeyQ"]); + assert_eq!(position.background_color.as_deref(), Some("#123456")); + assert_eq!(position.active_background_color.as_deref(), Some("#234567")); + assert_eq!(position.border_color.as_deref(), Some("#345678")); + assert_eq!(position.active_border_color.as_deref(), Some("#456789")); + assert_eq!(position.counter.fill.idle, "#ABCDEF"); + assert_eq!(position.counter.fill.active, "#FEDCBA"); + assert!(position.background_gradient.is_none()); + assert!(position.active_background_gradient.is_none()); + assert!(position.border_gradient.is_none()); + assert!(position.active_border_gradient.is_none()); + assert!(position.counter.fill_idle_gradient.is_none()); + assert!(position.counter.fill_active_gradient.is_none()); + let imported_value = serde_json::to_value(&imported).unwrap(); + assert!(!contains_gradient_fields(&imported_value)); + fixture.verify_unchanged(); + + println!( + "SIMULATION 5 PASS: legacyPresetParsed=true imported=true baseAndCounterColorsPreserved=true gradientsAbsent=true" + ); +} diff --git a/src/renderer/__tests__/gradientAxisHandle.test.tsx b/src/renderer/__tests__/gradientAxisHandle.test.tsx new file mode 100644 index 00000000..fd80b8f7 --- /dev/null +++ b/src/renderer/__tests__/gradientAxisHandle.test.tsx @@ -0,0 +1,250 @@ +// @vitest-environment jsdom +/** + * 온캔버스 그라데이션 축 핸들 로직 테스트 + * 축·색 분리 모델: 축 선/회전 핸들 드래그 → 각도만, 스톱 점 드래그 → pos만, + * 축 선 클릭 → 스톱 추가, 키보드 화살표 → 각도 커밋 + */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import GradientAxisOverlay from '@components/main/Grid/handles/GradientAxisHandle'; +import { useGradientEditStore } from '@stores/grid/useGradientEditStore'; +import type { GradientSpec } from '@src/types/color'; + +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const SPEC: GradientSpec = { + angle: 90, + stops: [ + { color: '#ff0000', pos: 0 }, + { color: '#0000ff', pos: 1 }, + ], +}; + +const positions = { + '4key': [{ dx: 100, dy: 100, width: 200, height: 100 }], +} as never; + +// jsdom에는 PointerEvent가 없어 MouseEvent 기반으로 합성 +const pointerEvent = ( + type: string, + init: { + pointerId: number; + clientX: number; + clientY: number; + button?: number; + }, +) => { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: init.clientX, + clientY: init.clientY, + button: init.button ?? 0, + // 드래그 중 버튼 유지 — buttons 0은 stale 드래그로 간주돼 취소됨 + buttons: type === 'pointerup' || type === 'pointercancel' ? 0 : 1, + }); + Object.defineProperty(event, 'pointerId', { value: init.pointerId }); + return event; +}; + +describe('GradientAxisOverlay 드래그 로직', () => { + let root: Root; + let host: HTMLDivElement; + let apply: ReturnType< + typeof vi.fn<(spec: GradientSpec, commit: boolean) => void> + >; + let selectStop: ReturnType void>>; + + beforeEach(() => { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + apply = vi.fn<(spec: GradientSpec, commit: boolean) => void>(); + selectStop = vi.fn<(index: number) => void>(); + act(() => { + useGradientEditStore.getState().setSession({ + anchor: { kind: 'key', index: 0 }, + spec: SPEC, + selectedIndex: 0, + selectStop, + apply, + }); + root.render( + , + ); + }); + }); + + afterEach(() => { + act(() => { + root.unmount(); + useGradientEditStore.getState().setSession(null); + }); + host.remove(); + }); + + const strip = () => host.querySelector('[role="slider"]') as HTMLElement; + const axisAnchor = (end: 'start' | 'end') => + host.querySelector(`[data-axis-anchor="${end}"]`) as HTMLElement; + const stopDot = (n: number) => + host.querySelector(`[aria-label="stop ${n}"]`) as HTMLElement; + + it('세션이 있으면 축 슬라이더·끝 앵커·스톱 점을 렌더한다', () => { + expect(strip()).toBeTruthy(); + expect(axisAnchor('start')).toBeTruthy(); + expect(axisAnchor('end')).toBeTruthy(); + expect(stopDot(1)).toBeTruthy(); + expect(stopDot(2)).toBeTruthy(); + }); + + it('축을 잡고 window에서 움직이면 각도 프리뷰·커밋이 적용된다', () => { + // 요소 중심 (200, 150), 각도 90 → 축은 수평 + act(() => { + strip().dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + clientX: 260, + clientY: 150, + }), + ); + }); + // 임계값 전에는 클릭(스톱 추가) 후보 — 아직 회전 아님 + expect(strip().style.cursor).toBe('grab'); + + act(() => { + window.dispatchEvent( + pointerEvent('pointermove', { + pointerId: 1, + clientX: 260, + clientY: 90, + }), + ); + }); + expect(strip().style.cursor).toBe('grabbing'); + expect(apply).toHaveBeenCalled(); + const [previewSpec, previewCommit] = apply.mock.calls.at(-1)!; + expect(previewCommit).toBe(false); + expect(previewSpec.angle).not.toBe(90); + + act(() => { + window.dispatchEvent( + pointerEvent('pointerup', { pointerId: 1, clientX: 260, clientY: 90 }), + ); + }); + const [finalSpec, finalCommit] = apply.mock.calls.at(-1)!; + expect(finalCommit).toBe(true); + expect(finalSpec.angle).not.toBe(90); + }); + + it('축 끝 앵커를 끌면 각도만 바뀐다', () => { + // end 앵커 위치: 선 끝 (300, 150) + act(() => { + axisAnchor('end').dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 3, + clientX: 300, + clientY: 150, + }), + ); + }); + act(() => { + window.dispatchEvent( + pointerEvent('pointermove', { + pointerId: 3, + clientX: 200, + clientY: 60, + }), + ); + }); + const [previewSpec, previewCommit] = apply.mock.calls.at(-1)!; + expect(previewCommit).toBe(false); + expect(previewSpec.angle).toBe(0); + expect(previewSpec.stops).toEqual(SPEC.stops); + + act(() => { + window.dispatchEvent( + pointerEvent('pointerup', { pointerId: 3, clientX: 200, clientY: 60 }), + ); + }); + const [finalSpec, finalCommit] = apply.mock.calls.at(-1)!; + expect(finalCommit).toBe(true); + expect(finalSpec.angle).toBe(0); + }); + + it('스톱 점을 끌면 축에 사영된 pos만 바뀌고 각도는 불변이다', () => { + act(() => { + stopDot(1).dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 2, + clientX: 100, + clientY: 150, + }), + ); + }); + expect(selectStop).toHaveBeenCalledWith(0); + + // 대각선 드래그 — 축(수평) 성분만 반영돼야 한다 + act(() => { + window.dispatchEvent( + pointerEvent('pointermove', { + pointerId: 2, + clientX: 160, + clientY: 90, + }), + ); + }); + expect(apply).toHaveBeenCalled(); + const [previewSpec, previewCommit] = apply.mock.calls.at(-1)!; + expect(previewCommit).toBe(false); + expect(previewSpec.angle).toBe(90); + expect(previewSpec.stops[0].pos).toBeCloseTo(0.3); + }); + + it('축 선을 클릭하면 그 위치에 스톱이 추가된다', () => { + act(() => { + strip().dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 4, + clientX: 240, + clientY: 150, + }), + ); + }); + act(() => { + window.dispatchEvent( + pointerEvent('pointerup', { pointerId: 4, clientX: 240, clientY: 150 }), + ); + }); + const [finalSpec, finalCommit] = apply.mock.calls.at(-1)!; + expect(finalCommit).toBe(true); + expect(finalSpec.stops).toHaveLength(3); + expect(finalSpec.stops[1].pos).toBeCloseTo(0.7); + expect(finalSpec.stops[1].color).toBe('#ff0000'); + expect(selectStop).toHaveBeenCalledWith(1); + }); + + it('키보드 화살표로 각도가 커밋된다', () => { + act(() => { + strip().dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + ); + }); + expect(apply).toHaveBeenCalledWith( + expect.objectContaining({ angle: 91 }), + true, + ); + }); +}); diff --git a/src/renderer/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index d4c801ae..634fefc8 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.tsx @@ -1961,6 +1961,7 @@ const PropertiesPanel: React.FC = ({ handleBatchNoteColorChangeComplete, handleBatchGlowColorChange, handleBatchGlowColorChangeComplete, + handleBatchGradientCommit, } = useBatchHandlers({ selectedKeyLikeElements: selectedBatchStyleElements as { type: 'key' | 'stat' | 'graph' | 'knob'; @@ -2685,6 +2686,7 @@ const PropertiesPanel: React.FC = ({ handleBatchResize={handleBatchResize} handleBatchStyleChange={handleBatchStyleChange} handleBatchStyleChangeComplete={handleBatchStyleChangeComplete} + handleBatchGradientCommit={handleBatchGradientCommit} handleKeyOnlyStyleChangeComplete={handleKeyOnlyStyleChangeComplete} handleBatchCounterUpdate={handleBatchCounterUpdate} handleBatchNoteColorChange={handleBatchNoteColorChange} @@ -2782,6 +2784,7 @@ const PropertiesPanel: React.FC = ({ handleBatchResize={handleBatchResize} handleBatchStyleChange={handleBatchStyleChange} handleBatchStyleChangeComplete={handleBatchStyleChangeComplete} + handleBatchGradientCommit={handleBatchGradientCommit} handleKnobBatchSharedSetting={handleKnobBatchSharedSetting} getMixedValueKnobs={getMixedValueKnobs} getMixedValueKnobsAsKey={getMixedValueKnobsAsKey} @@ -2827,6 +2830,7 @@ const PropertiesPanel: React.FC = ({ handleBatchResize={handleBatchResize} handleBatchStyleChange={handleBatchStyleChange} handleBatchStyleChangeComplete={handleBatchStyleChangeComplete} + handleBatchGradientCommit={handleBatchGradientCommit} handleGraphBatchSharedSetting={handleGraphBatchSharedSetting} getMixedValueGraphs={getMixedValueGraphs} getMixedValueGraphsAsKey={getMixedValueGraphsAsKey} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx index 01c62a62..2330df4c 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx @@ -19,6 +19,8 @@ import { } from '@utils/cardRecipes'; import ColorPicker from '@components/main/Modal/content/pickers/ColorPicker'; import { ColorSwatchButton } from '@components/main/Modal/content/pickers/ColorSwatch'; +import { useGradientColorState } from '@hooks/pickers/useGradientColorState'; +import { gradientToCss } from '@src/types/color'; // ============================================================================ // 속성 행 @@ -624,6 +626,10 @@ export const ColorInput: React.FC = ({ panelElement, isOpen: externalIsOpen, onToggle: externalOnToggle, + gradientValue, + activeGradientValue, + onModeCommit, + canvasAnchor, }) => { // 외부 제어 모드인지 확인 const isControlled = @@ -722,6 +728,40 @@ export const ColorInput: React.FC = ({ return '#ffffff'; }; + // ── gradient 배선 — onModeCommit이 주어진 경우에만 활성화 ── + const supportsGradient = onModeCommit !== undefined; + const storedGradient = + stateMode === 'active' + ? activeGradientValue ?? null + : gradientValue ?? null; + + const gradientState = useGradientColorState({ + pair: supportsGradient + ? { + color: + showStateTabs && stateMode === 'active' + ? localActiveColor + : localColor, + gradient: storedGradient, + } + : {}, + fallbackColor: '#ffffff', + contextKey: `${_stableId}:${stateMode}`, + canvasAnchor: open ? canvasAnchor : undefined, + onPreview: (modeValue) => { + if (modeValue.mode === 'solid') handleColorChange(modeValue.color); + }, + onCommit: (modeValue) => { + const base = + modeValue.mode === 'solid' + ? modeValue.color + : modeValue.spec.stops[0]?.color ?? '#ffffff'; + if (showStateTabs && stateMode === 'active') setLocalActiveColor(base); + else setLocalColor(base); + onModeCommit?.(stateMode, modeValue); + }, + }); + return ( <> = ({ ? localActiveColor : localColor, )} + image={ + supportsGradient && storedGradient + ? gradientToCss(storedGradient) + : undefined + } /> {open && ( = ({ referenceRef={buttonRef} panelElement={panelElement} color={ - showStateTabs && stateMode === 'active' + supportsGradient + ? gradientState.pickerColor + : showStateTabs && stateMode === 'active' ? localActiveColor : localColor } - onColorChange={handleColorChange} - onColorChangeComplete={handleColorChangeComplete} + onColorChange={ + supportsGradient + ? (c: string) => gradientState.handlePickerColorChange(c, false) + : handleColorChange + } + onColorChangeComplete={ + supportsGradient + ? (c: string) => gradientState.handlePickerColorChange(c, true) + : handleColorChangeComplete + } onClose={handleClose} interactiveRefs={interactiveRefs} solidOnly={solidOnly} stateMode={showStateTabs ? stateMode : undefined} onStateModeChange={showStateTabs ? handleStateModeChange : undefined} + headerSlot={supportsGradient ? gradientState.headerSlot : undefined} + footerSlot={supportsGradient ? gradientState.footerSlot : undefined} + gradientSpec={ + supportsGradient ? gradientState.paletteGradientSpec : undefined + } + onGradientSpecSelect={ + supportsGradient + ? gradientState.handleGradientSpecSelect + : undefined + } /> )} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx index 97f885b2..01f26309 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx @@ -10,6 +10,7 @@ import type { GraphItemType, } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; +import type { ColorModeValue } from '@src/types/color'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; import { PANEL_ROOT_CLASS, PANEL_HEADER_CLASS } from '../panelChrome'; import { @@ -132,6 +133,11 @@ interface BatchKeyLikePanelProps { property: keyof KeyPosition, value: unknown, ) => void; + handleBatchGradientCommit?: ( + target: 'backgroundColor' | 'borderColor', + state: 'idle' | 'active', + value: ColorModeValue, + ) => void; handleKeyOnlyStyleChangeComplete: ( property: keyof KeyPosition, value: KeyPosition[keyof KeyPosition], @@ -221,6 +227,7 @@ export const BatchKeyLikePanel: React.FC = ({ handleBatchResize, handleBatchStyleChange, handleBatchStyleChangeComplete, + handleBatchGradientCommit, handleKeyOnlyStyleChangeComplete, handleBatchCounterUpdate, handleGraphBatchSharedSetting, @@ -769,6 +776,7 @@ export const BatchKeyLikePanel: React.FC = ({ handleBatchResize={handleBatchResize} handleBatchStyleChange={handleBatchStyleChange} handleBatchStyleChangeComplete={handleBatchStyleChangeComplete} + handleBatchGradientCommit={handleBatchGradientCommit} getKeyOnlyMixedValue={getMixedValueKeysOnly} handleKeyOnlyStyleChangeComplete={ handleKeyOnlyStyleChangeComplete @@ -1011,6 +1019,11 @@ interface BatchGraphOnlyPanelProps { property: keyof KeyPosition, value: unknown, ) => void; + handleBatchGradientCommit?: ( + target: 'backgroundColor' | 'borderColor', + state: 'idle' | 'active', + value: ColorModeValue, + ) => void; handleGraphBatchSharedSetting: (updates: Partial) => void; getMixedValueGraphs: MixedValueGetter; getMixedValueGraphsAsKey: MixedValueGetter; @@ -1046,6 +1059,7 @@ export const BatchGraphOnlyPanel: React.FC = ({ handleBatchResize, handleBatchStyleChange, handleBatchStyleChangeComplete, + handleBatchGradientCommit, handleGraphBatchSharedSetting, getMixedValueGraphs, getMixedValueGraphsAsKey, @@ -1277,6 +1291,7 @@ export const BatchGraphOnlyPanel: React.FC = ({ handleBatchResize={handleBatchResize} handleBatchStyleChange={handleBatchStyleChange} handleBatchStyleChangeComplete={handleBatchStyleChangeComplete} + handleBatchGradientCommit={handleBatchGradientCommit} showBatchImagePicker={showBatchImagePicker} onToggleBatchImagePicker={() => setShowBatchImagePicker(!showBatchImagePicker) @@ -1372,6 +1387,11 @@ interface BatchKnobOnlyPanelProps { property: keyof KeyPosition, value: unknown, ) => void; + handleBatchGradientCommit?: ( + target: 'backgroundColor' | 'borderColor', + state: 'idle' | 'active', + value: ColorModeValue, + ) => void; handleKnobBatchSharedSetting: (updates: Partial) => void; getMixedValueKnobs: MixedValueGetter; getMixedValueKnobsAsKey: MixedValueGetter; @@ -1407,6 +1427,7 @@ export const BatchKnobOnlyPanel: React.FC = ({ handleBatchResize, handleBatchStyleChange, handleBatchStyleChangeComplete, + handleBatchGradientCommit, handleKnobBatchSharedSetting, getMixedValueKnobs, getMixedValueKnobsAsKey, @@ -1557,6 +1578,7 @@ export const BatchKnobOnlyPanel: React.FC = ({ handleBatchResize={handleBatchResize} handleBatchStyleChange={handleBatchStyleChange} handleBatchStyleChangeComplete={handleBatchStyleChangeComplete} + handleBatchGradientCommit={handleBatchGradientCommit} showBatchImagePicker={showBatchImagePicker} onToggleBatchImagePicker={() => setShowBatchImagePicker(!showBatchImagePicker) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx index efc9a02d..f534af85 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect } from 'react'; import { createPortal } from 'react-dom'; import type { KeyPosition } from '@src/types/key/keys'; +import type { ColorModeValue } from '@src/types/color'; import { PropertyRow, NumberInput, @@ -17,6 +18,7 @@ import { DEFAULT_ELEMENT_ACTIVE_FONT, DEFAULT_ELEMENT_BORDER, DEFAULT_ELEMENT_ACTIVE_BORDER, + DEFAULT_ELEMENT_BORDER_WIDTH, DEFAULT_ELEMENT_RADIUS, DEFAULT_ELEMENT_FONT_WEIGHT, } from '@utils/core/elementDefaults'; @@ -73,6 +75,11 @@ interface BatchStyleTabContentProps { property: keyof KeyPosition, value: unknown, ) => void; + handleBatchGradientCommit?: ( + target: 'backgroundColor' | 'borderColor', + state: 'idle' | 'active', + value: ColorModeValue, + ) => void; // 키 전용 (사운드 등) getKeyOnlyMixedValue?: ( getter: (pos: KeyPosition) => T | undefined, @@ -108,6 +115,7 @@ const BatchStyleTabContent: React.FC = ({ handleBatchResize, handleBatchStyleChange, handleBatchStyleChangeComplete, + handleBatchGradientCommit, getKeyOnlyMixedValue, handleKeyOnlyStyleChangeComplete, showBatchImagePicker, @@ -555,6 +563,7 @@ const BatchStyleTabContent: React.FC = ({ Mixed ) : null} pos.backgroundColor, DEFAULT_ELEMENT_BG) .value @@ -581,6 +590,24 @@ const BatchStyleTabContent: React.FC = ({ handleBatchStyleChangeComplete('activeBackgroundColor', color) } panelElement={panelElement} + canvasAnchor={{ kind: 'batch' }} + gradientValue={ + getMixedValue((pos) => pos.backgroundGradient ?? null, null).value + } + activeGradientValue={ + getMixedValue((pos) => pos.activeBackgroundGradient ?? null, null) + .value + } + onModeCommit={ + handleBatchGradientCommit + ? (state, modeValue) => + handleBatchGradientCommit( + 'backgroundColor', + state, + modeValue, + ) + : undefined + } /> @@ -598,6 +625,7 @@ const BatchStyleTabContent: React.FC = ({ Mixed ) : null} pos.borderColor, DEFAULT_ELEMENT_BORDER) .value @@ -622,26 +650,36 @@ const BatchStyleTabContent: React.FC = ({ handleBatchStyleChangeComplete('activeBorderColor', color) } panelElement={panelElement} + canvasAnchor={{ kind: 'batch' }} + gradientValue={ + getMixedValue((pos) => pos.borderGradient ?? null, null).value + } + activeGradientValue={ + getMixedValue((pos) => pos.activeBorderGradient ?? null, null) + .value + } + onModeCommit={ + handleBatchGradientCommit + ? (state, modeValue) => + handleBatchGradientCommit('borderColor', state, modeValue) + : undefined + } /> {/* 테두리 두께 */} {getMixedValue( - (pos) => - pos.borderWidth ?? - (pos.borderColor || pos.activeBorderColor ? 3 : 0), - 0, + (pos) => pos.borderWidth ?? DEFAULT_ELEMENT_BORDER_WIDTH, + DEFAULT_ELEMENT_BORDER_WIDTH, ).isMixed ? ( Mixed ) : null} - pos.borderWidth ?? - (pos.borderColor || pos.activeBorderColor ? 3 : 0), - 0, + (pos) => pos.borderWidth ?? DEFAULT_ELEMENT_BORDER_WIDTH, + DEFAULT_ELEMENT_BORDER_WIDTH, ).value } onChange={(value) => diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts b/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts index 79b2b175..615b95fb 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts @@ -4,6 +4,7 @@ import type { KeyCounterSettings, } from '@src/types/key/keys'; import { normalizeCounterSettings } from '@src/types/key/keys'; +import { gradientPairPatch, type ColorModeValue } from '@src/types/color'; import type { StatItemPosition } from '@src/types/key/statItems'; import type { GraphItemPosition } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; @@ -968,6 +969,93 @@ export function useBatchHandlers({ ] as KeyLikeBatchUpdate[]); }; + // 그라데이션 커밋 — 배경/테두리 쌍(base+sibling)을 선택 요소 전체에 atomic 적용 + const handleBatchGradientCommit = ( + target: 'backgroundColor' | 'borderColor', + state: 'idle' | 'active', + value: ColorModeValue, + ) => { + const isBg = target === 'backgroundColor'; + const baseField = + state === 'active' + ? isBg + ? 'activeBackgroundColor' + : 'activeBorderColor' + : target; + const pairPatch = gradientPairPatch( + baseField, + value, + ) as Partial; + + const buildUpdate = ( + index: number, + pos: KeyPosition | undefined, + ): { index: number } & Partial => { + const update: { index: number } & Partial = { + index, + ...pairPatch, + }; + // idle 편집 시 active 쌍이 비어 있으면 현재 표시되던 active 모습을 쌍으로 보존 + if (state === 'idle' && pos) { + const activeBase = isBg + ? pos.activeBackgroundColor + : pos.activeBorderColor; + const activeGradient = isBg + ? pos.activeBackgroundGradient + : pos.activeBorderGradient; + if (activeBase == null && activeGradient == null) { + const fallback = isBg + ? pos.backgroundColor ?? DEFAULT_ACTIVE_BACKGROUND_COLOR + : pos.borderColor ?? DEFAULT_ACTIVE_BORDER_COLOR; + if (isBg) { + update.activeBackgroundColor = fallback; + if (pos.backgroundGradient) { + update.activeBackgroundGradient = pos.backgroundGradient; + } + } else { + update.activeBorderColor = fallback; + if (pos.borderGradient) { + update.activeBorderGradient = pos.borderGradient; + } + } + } + } + return update; + }; + + const currentKeys = keyPositions[selectedKeyType] || []; + const currentStats = statPositions[selectedKeyType] || []; + const currentGraphs = graphPositions?.[selectedKeyType] || []; + const currentKnobs = knobPositions?.[selectedKeyType] || []; + + dispatchKeyLikeUpdates([ + ...selectedKeys + .filter((el) => el.index !== undefined) + .map((el) => ({ + type: 'key' as const, + ...buildUpdate(el.index!, currentKeys[el.index!]), + })), + ...selectedStats + .filter((el) => el.index !== undefined) + .map((el) => ({ + type: 'stat' as const, + ...buildUpdate(el.index!, currentStats[el.index!]), + })), + ...selectedGraphs + .filter((el) => el.index !== undefined) + .map((el) => ({ + type: 'graph' as const, + ...buildUpdate(el.index!, currentGraphs[el.index!]), + })), + ...selectedKnobs + .filter((el) => el.index !== undefined) + .map((el) => ({ + type: 'knob' as const, + ...buildUpdate(el.index!, currentKnobs[el.index!]), + })), + ] as KeyLikeBatchUpdate[]); + }; + // 정렬 핸들러 const handleBatchAlign = ( direction: 'left' | 'centerH' | 'right' | 'top' | 'centerV' | 'bottom', @@ -1416,6 +1504,7 @@ export function useBatchHandlers({ return { handleBatchStyleChange, handleBatchStyleChangeComplete, + handleBatchGradientCommit, handleKeyOnlyStyleChangeComplete, handleBatchAlign, handleBatchDistribute, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx index 453f653e..5b23f4e4 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx @@ -21,6 +21,13 @@ import CounterAnimationPicker from '@components/main/Modal/content/pickers/Count import { usePanelNav } from '../PanelNavContext'; import { ColorSwatchButton } from '@components/main/Modal/content/pickers/ColorSwatch'; import { DEFAULT_COUNTER_FONT_SIZE } from '@utils/core/elementDefaults'; +import { useGradientColorState } from '@hooks/pickers/useGradientColorState'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { + counterFillPair, + gradientToCss, + type ColorModeValue, +} from '@src/types/color'; // 인-패널 서브 페이지 키 — 트리거 사이트별 유니크 const FONT_PAGE_KEY = 'single-counter:font'; @@ -44,6 +51,7 @@ const CounterTabContent: React.FC = ({ const [pickerFor, setPickerFor] = useState(null); const pickerOpen = pickerFor !== null; const [colorState, setColorState] = useState('idle'); + const selectedKeyType = useKeyStore((state) => state.selectedKeyType); // 인-패널 내비게이션 (폰트/애니메이션 서브 페이지) const { activePageKey, renderPageKey, openPage, closePage, pageHost } = @@ -168,6 +176,53 @@ const CounterTabContent: React.FC = ({ } }; + // ── fill 그라데이션 배선 (stroke는 단색 유지) ── + + const storedFillGradient = + colorState === 'active' + ? counterSettings.fillActiveGradient ?? null + : counterSettings.fillIdleGradient ?? null; + + const handleFillCommit = (value: ColorModeValue) => { + const pair = counterFillPair(value); + const key = colorState === 'active' ? 'fillActive' : 'fillIdle'; + setLocalColors((prev) => ({ ...prev, [key]: pair.color })); + if (colorState === 'active') { + handleCounterUpdate({ + fill: { ...counterSettings.fill, active: pair.color }, + fillActiveGradient: pair.gradient, + }); + } else { + handleCounterUpdate({ + fill: { ...counterSettings.fill, idle: pair.color }, + fillIdleGradient: pair.gradient, + }); + } + }; + + const fillGradientState = useGradientColorState({ + pair: + pickerFor === 'fill' + ? { + color: activeColorFor('fill', colorState), + gradient: storedFillGradient, + } + : {}, + fallbackColor: '#ffffff', + // 요소 종류·키 모드 포함 — 형식 왕복 기억이 다른 대상과 교차하지 않게 + contextKey: `${ + isStat ? 'stat' : 'key' + }:${selectedKeyType}:${keyIndex}:fill:${colorState}`, + canvasAnchor: + pickerFor === 'fill' + ? { kind: isStat ? 'stat' : 'key', index: keyIndex } + : undefined, + onPreview: (value) => { + if (value.mode === 'solid') handleColorChange(value.color); + }, + onCommit: handleFillCommit, + }); + return ( <> @@ -278,6 +333,9 @@ const CounterTabContent: React.FC = ({ className="w-[23px] h-[23px] rounded-md cursor-pointer transition-shadow flex-shrink-0" surfaceClassName="rounded-md" color={getDisplayColor(activeColorFor('fill', colorState))} + image={ + storedFillGradient ? gradientToCss(storedFillGradient) : undefined + } /> @@ -389,9 +447,21 @@ const CounterTabContent: React.FC = ({ open={pickerOpen} referenceRef={pickerFor === 'fill' ? fillBtnRef : strokeBtnRef} panelElement={panelElement} - color={activeColorFor(pickerFor as 'fill' | 'stroke', colorState)} - onColorChange={(c: string) => handleColorChange(c)} - onColorChangeComplete={(c: string) => handleColorChangeComplete(c)} + color={ + pickerFor === 'fill' + ? fillGradientState.pickerColor + : activeColorFor(pickerFor as 'fill' | 'stroke', colorState) + } + onColorChange={(c: string) => + pickerFor === 'fill' + ? fillGradientState.handlePickerColorChange(c, false) + : handleColorChange(c) + } + onColorChangeComplete={(c: string) => + pickerFor === 'fill' + ? fillGradientState.handlePickerColorChange(c, true) + : handleColorChangeComplete(c) + } onClose={() => setPickerFor(null)} solidOnly={true} interactiveRefs={colorPickerInteractiveRefs} @@ -399,6 +469,22 @@ const CounterTabContent: React.FC = ({ onStateModeChange={(mode: string) => setColorState(mode as ColorState) } + headerSlot={ + pickerFor === 'fill' ? fillGradientState.headerSlot : undefined + } + footerSlot={ + pickerFor === 'fill' ? fillGradientState.footerSlot : undefined + } + gradientSpec={ + pickerFor === 'fill' + ? fillGradientState.paletteGradientSpec + : undefined + } + onGradientSpecSelect={ + pickerFor === 'fill' + ? fillGradientState.handleGradientSpecSelect + : undefined + } /> )} @@ -446,6 +532,10 @@ const CounterTabContent: React.FC = ({ activeBackgroundColor: keyPosition.activeBackgroundColor, activeBorderColor: keyPosition.activeBorderColor, activeFontColor: keyPosition.activeFontColor, + backgroundGradient: keyPosition.backgroundGradient, + activeBackgroundGradient: keyPosition.activeBackgroundGradient, + borderGradient: keyPosition.borderGradient, + activeBorderGradient: keyPosition.activeBorderGradient, useInlineStyles: keyPosition.useInlineStyles, isStat, }} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx index 2f8b3e48..95fb04f9 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx @@ -12,6 +12,13 @@ import type { GraphItemType, } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; +import { + gradientPairPatch, + gradientToCss, + type ColorModeValue, + type GradientSpec, +} from '@src/types/color'; +import { useGradientColorState } from '@hooks/pickers/useGradientColorState'; import { axisEventBus } from '@utils/core/axisEventBus'; import { DEFAULT_ELEMENT_BG, @@ -552,6 +559,14 @@ export const SingleGraphPanel: React.FC = ({ backgroundColor: value, }) } + gradientValue={singleGraphPosition.backgroundGradient ?? null} + canvasAnchor={{ kind: 'graph', index: singleGraphIndex }} + onModeCommit={(_state, modeValue) => + handleGraphUpdate({ + index: singleGraphIndex, + ...gradientPairPatch('backgroundColor', modeValue), + }) + } colorId={`graph-bg-color-${selectedKeyType}-${singleGraphIndex}`} panelElement={panelElement} /> @@ -571,6 +586,14 @@ export const SingleGraphPanel: React.FC = ({ borderColor: value, }) } + gradientValue={singleGraphPosition.borderGradient ?? null} + canvasAnchor={{ kind: 'graph', index: singleGraphIndex }} + onModeCommit={(_state, modeValue) => + handleGraphUpdate({ + index: singleGraphIndex, + ...gradientPairPatch('borderColor', modeValue), + }) + } colorId={`graph-border-color-${selectedKeyType}-${singleGraphIndex}`} panelElement={panelElement} /> @@ -920,30 +943,93 @@ export const SingleKnobPanel: React.FC = ({ setLocalColors((prev) => ({ ...prev, [prop]: color })); }; - const handleColorChangeComplete = ( - target: KnobColorTarget, - color: string, - ) => { - const prop = resolveColorProperty(target); - setLocalColors((prev) => ({ ...prev, [prop]: color })); + // ── 그라데이션 배선 (키 패널과 동일 패턴) — 단색 커밋도 이 경로로 통합 ── + + const storedGradientOf = (prop: KnobColorProperty): GradientSpec | null => { + switch (prop) { + case 'backgroundColor': + return singleKnobPosition.backgroundGradient ?? null; + case 'activeBackgroundColor': + return singleKnobPosition.activeBackgroundGradient ?? null; + case 'borderColor': + return singleKnobPosition.borderGradient ?? null; + case 'activeBorderColor': + return singleKnobPosition.activeBorderGradient ?? null; + default: + return null; + } + }; + + const gradientSpecFor = (target: KnobColorTarget): GradientSpec | null => { + const idleGradient = storedGradientOf(target); + if (colorState !== 'active') return idleGradient; + const activeProp = activeColorPropertyFor(target); + const activeGradient = storedGradientOf(activeProp); + const activeHasValue = + isNonEmptyString(singleKnobPosition[activeProp]) || + activeGradient != null; + return activeHasValue ? activeGradient : idleGradient; + }; + + const handleGradientCommit = (value: ColorModeValue) => { + if (!pickerFor) return; + const prop = resolveColorProperty(pickerFor); + const patch = gradientPairPatch( + prop as Parameters[0], + value, + ) as Partial; + + const baseColor = patch[prop]; + if (typeof baseColor === 'string') { + setLocalColors((prev) => ({ ...prev, [prop]: baseColor })); + } - const updates: Partial = { - [prop]: color, - } as Partial; + const updates: Partial = { ...patch }; - // idle 변경 시 active 값이 비어 있으면 현재 표시되던 active 값을 함께 저장 - // (active가 idle로 덮이는 현상 방지 — 키 패널과 동일) + // idle 편집 시 active 쌍이 비어 있으면 현재 표시되던 active 모습을 쌍으로 보존 if (colorState !== 'active') { - const activeProp = activeColorPropertyFor(target); - const currentActive = singleKnobPosition[activeProp]; - if (!isNonEmptyString(currentActive)) { + const activeProp = activeColorPropertyFor(pickerFor); + const activeEmpty = + !isNonEmptyString(singleKnobPosition[activeProp]) && + storedGradientOf(activeProp) == null; + if (activeEmpty) { updates[activeProp] = localColors[activeProp]; + const prevIdleGradient = storedGradientOf(pickerFor); + if (prevIdleGradient) { + const activeSibling = + pickerFor === 'backgroundColor' + ? 'activeBackgroundGradient' + : 'activeBorderGradient'; + updates[activeSibling] = prevIdleGradient; + } } } handleKnobUpdate({ index: singleKnobIndex, ...updates }); }; + const knobGradientState = useGradientColorState({ + pair: pickerFor + ? { + color: colorValueFor(pickerFor), + gradient: gradientSpecFor(pickerFor), + } + : {}, + fallbackColor: '#ffffff', + contextKey: `knob:${selectedKeyType}:${singleKnobIndex}:${ + pickerFor ?? 'none' + }:${colorState}`, + canvasAnchor: pickerFor + ? { kind: 'knob', index: singleKnobIndex } + : undefined, + onPreview: (value) => { + if (value.mode === 'solid' && pickerFor) { + handleColorChange(pickerFor, value.color); + } + }, + onCommit: handleGradientCommit, + }); + const handlePickerToggle = (target: KnobColorTarget) => { setPickerFor((prev) => (prev === target ? null : target)); }; @@ -1132,6 +1218,10 @@ export const SingleKnobPanel: React.FC = ({ className="w-[23px] h-[23px] rounded-md cursor-pointer transition-shadow flex-shrink-0" surfaceClassName="rounded-md" color={colorValueFor('backgroundColor')} + image={(() => { + const spec = gradientSpecFor('backgroundColor'); + return spec ? gradientToCss(spec) : undefined; + })()} /> @@ -1147,6 +1237,10 @@ export const SingleKnobPanel: React.FC = ({ className="w-[23px] h-[23px] rounded-md cursor-pointer transition-shadow flex-shrink-0" surfaceClassName="rounded-md" color={colorValueFor('borderColor')} + image={(() => { + const spec = gradientSpecFor('borderColor'); + return spec ? gradientToCss(spec) : undefined; + })()} /> @@ -1289,16 +1383,22 @@ export const SingleKnobPanel: React.FC = ({ pickerFor === 'backgroundColor' ? bgColorBtnRef : borderColorBtnRef } panelElement={panelElement} - color={colorValueFor(pickerFor)} - onColorChange={(c: string) => handleColorChange(pickerFor, c)} + color={knobGradientState.pickerColor} + onColorChange={(c: string) => + knobGradientState.handlePickerColorChange(c, false) + } onColorChangeComplete={(c: string) => - handleColorChangeComplete(pickerFor, c) + knobGradientState.handlePickerColorChange(c, true) } onClose={() => setPickerFor(null)} solidOnly={true} stateMode={colorState} onStateModeChange={setColorState} interactiveRefs={[bgColorBtnRef, borderColorBtnRef]} + headerSlot={knobGradientState.headerSlot} + footerSlot={knobGradientState.footerSlot} + gradientSpec={knobGradientState.paletteGradientSpec} + onGradientSpecSelect={knobGradientState.handleGradientSpecSelect} /> )}
@@ -1556,6 +1656,10 @@ export const SingleKeyStatPanel: React.FC = ({ onPositionChange={handleKeyLikePositionChange} onKeyUpdate={handleKeyLikeUpdate} onKeyPreview={handleKeyLikePreview} + canvasAnchor={{ + kind: isSingleStat ? 'stat' : 'key', + index: keyLikeIndex, + }} onKeyMappingChange={isSingleStat ? undefined : onKeyMappingChange} isListening={isListening} onKeyListen={isSingleStat ? undefined : handleKeyListen} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx index 2d9796e9..67e67175 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx @@ -11,12 +11,20 @@ import { FontStyleToggle, } from '../PropertyInputs'; import { usePanelNav } from '../PanelNavContext'; +import { useKeyStore } from '@stores/data/useKeyStore'; import ImagePicker from '../../../Modal/content/pickers/ImagePicker'; import ColorPicker from '../../../Modal/content/pickers/ColorPicker'; import FontPicker from '../../../Modal/content/pickers/FontPicker'; import SoundPicker from '../../../Modal/content/pickers/SoundPicker'; import Checkbox from '../../../common/Checkbox'; import { ColorSwatchButton } from '../../../Modal/content/pickers/ColorSwatch'; +import { useGradientColorState } from '@hooks/pickers/useGradientColorState'; +import { + gradientPairPatch, + gradientToCss, + type ColorModeValue, + type GradientSpec, +} from '@src/types/color'; import { DEFAULT_ELEMENT_BG, DEFAULT_ELEMENT_ACTIVE_BG, @@ -24,6 +32,7 @@ import { DEFAULT_ELEMENT_ACTIVE_FONT, DEFAULT_ELEMENT_BORDER, DEFAULT_ELEMENT_ACTIVE_BORDER, + DEFAULT_ELEMENT_BORDER_WIDTH, DEFAULT_ELEMENT_RADIUS, DEFAULT_ELEMENT_FONT_WEIGHT, } from '@utils/core/elementDefaults'; @@ -86,6 +95,7 @@ const StyleTabContent: React.FC = ({ imageButtonRef, panelElement, useCustomCSS = false, + canvasAnchor, t, // 로컬 상태 localDx, @@ -111,6 +121,7 @@ const StyleTabContent: React.FC = ({ // 통합 피커 상태 const [pickerFor, setPickerFor] = useState(null); const [colorState, setColorState] = useState('idle'); + const selectedKeyType = useKeyStore((state) => state.selectedKeyType); // 컬러 버튼 refs const bgColorBtnRef = useRef(null); @@ -281,6 +292,102 @@ const StyleTabContent: React.FC = ({ onKeyUpdate({ index: keyIndex, ...updates }); }; + // ── 그라데이션 배선 (배경·테두리 전용, 글꼴 색상은 단색 유지) ── + + type GradientColorTarget = 'backgroundColor' | 'borderColor'; + + const gradientTarget: GradientColorTarget | null = + pickerFor === 'backgroundColor' || pickerFor === 'borderColor' + ? pickerFor + : null; + + // 상태별 저장된 gradient 형제 값 (쌍 단위 폴백 — resolveStatePair와 동일 규칙) + const storedGradientOf = (prop: StyleColorProperty): GradientSpec | null => { + switch (prop) { + case 'backgroundColor': + return keyPosition.backgroundGradient ?? null; + case 'activeBackgroundColor': + return keyPosition.activeBackgroundGradient ?? null; + case 'borderColor': + return keyPosition.borderGradient ?? null; + case 'activeBorderColor': + return keyPosition.activeBorderGradient ?? null; + default: + return null; + } + }; + + const gradientSpecFor = ( + target: GradientColorTarget, + ): GradientSpec | null => { + const idleGradient = storedGradientOf(target); + if (colorState !== 'active') return idleGradient; + const activeProp = activeColorPropertyFor(target); + const activeGradient = storedGradientOf(activeProp); + const activeHasValue = + isNonEmptyString(keyPosition[activeProp]) || activeGradient != null; + return activeHasValue ? activeGradient : idleGradient; + }; + + const handleGradientPreview = (value: ColorModeValue) => { + if (!gradientTarget) return; + if (value.mode === 'solid') handleColorChange(gradientTarget, value.color); + }; + + const handleGradientCommit = (value: ColorModeValue) => { + if (!gradientTarget) return; + const prop = resolveColorProperty(gradientTarget); + const patch = gradientPairPatch( + prop as Parameters[0], + value, + ) as Partial; + + const baseColor = patch[prop]; + if (typeof baseColor === 'string') { + setLocalColors((prev) => ({ ...prev, [prop]: baseColor })); + } + + const updates: Partial = { ...patch }; + + // idle 편집 시 active 쌍이 비어 있으면 현재 표시되던 active 모습을 쌍으로 보존 + if (colorState !== 'active') { + const activeProp = activeColorPropertyFor(gradientTarget); + const activeEmpty = + !isNonEmptyString(keyPosition[activeProp]) && + storedGradientOf(activeProp) == null; + if (activeEmpty) { + updates[activeProp] = localColors[activeProp]; + const prevIdleGradient = storedGradientOf(gradientTarget); + if (prevIdleGradient) { + const activeSibling = + gradientTarget === 'backgroundColor' + ? 'activeBackgroundGradient' + : 'activeBorderGradient'; + updates[activeSibling] = prevIdleGradient; + } + } + } + + onKeyUpdate({ index: keyIndex, ...updates }); + }; + + const gradientState = useGradientColorState({ + pair: gradientTarget + ? { + color: colorValueFor(gradientTarget), + gradient: gradientSpecFor(gradientTarget), + } + : {}, + fallbackColor: '#ffffff', + // 요소 종류·키 모드 포함 — 형식 왕복 기억이 다른 대상과 교차하지 않게 + contextKey: `${ + canvasAnchor?.kind ?? 'key' + }:${selectedKeyType}:${keyIndex}:${pickerFor ?? 'none'}:${colorState}`, + canvasAnchor: gradientTarget ? canvasAnchor : undefined, + onPreview: handleGradientPreview, + onCommit: handleGradientCommit, + }); + // 위치 변경 핸들러 const handlePositionXChange = (value: number) => { if (onLocalDxChange) { @@ -509,6 +616,10 @@ const StyleTabContent: React.FC = ({ className="w-[23px] h-[23px] rounded-md cursor-pointer transition-shadow flex-shrink-0" surfaceClassName="rounded-md" color={getDisplayColor(colorValueFor('backgroundColor'))} + image={(() => { + const spec = gradientSpecFor('backgroundColor'); + return spec ? gradientToCss(spec) : undefined; + })()} /> @@ -522,16 +633,17 @@ const StyleTabContent: React.FC = ({ className="w-[23px] h-[23px] rounded-md cursor-pointer transition-shadow flex-shrink-0" surfaceClassName="rounded-md" color={getDisplayColor(colorValueFor('borderColor'))} + image={(() => { + const spec = gradientSpecFor('borderColor'); + return spec ? gradientToCss(spec) : undefined; + })()} /> {/* 테두리 두께 */} handleStyleChangeComplete('borderWidth', value) } @@ -784,18 +896,34 @@ const StyleTabContent: React.FC = ({ : fontColorBtnRef } panelElement={panelElement} - color={colorValueFor(pickerFor as StyleColorTarget)} + color={ + gradientTarget + ? gradientState.pickerColor + : colorValueFor(pickerFor as StyleColorTarget) + } onColorChange={(c: string) => - handleColorChange(pickerFor as StyleColorTarget, c) + gradientTarget + ? gradientState.handlePickerColorChange(c, false) + : handleColorChange(pickerFor as StyleColorTarget, c) } onColorChangeComplete={(c: string) => - handleColorChangeComplete(pickerFor as StyleColorTarget, c) + gradientTarget + ? gradientState.handlePickerColorChange(c, true) + : handleColorChangeComplete(pickerFor as StyleColorTarget, c) } onClose={() => setPickerFor(null)} solidOnly={true} stateMode={colorState} onStateModeChange={setColorState} interactiveRefs={colorPickerInteractiveRefs} + headerSlot={gradientTarget ? gradientState.headerSlot : undefined} + footerSlot={gradientTarget ? gradientState.footerSlot : undefined} + gradientSpec={ + gradientTarget ? gradientState.paletteGradientSpec : undefined + } + onGradientSpecSelect={ + gradientTarget ? gradientState.handleGradientSpecSelect : undefined + } /> )} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/types.ts b/src/renderer/components/main/Grid/PropertiesPanel/types.ts index 78f9a62f..62d47cee 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/types.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/types.ts @@ -1,4 +1,6 @@ import type { KeyPosition } from '@src/types/key/keys'; +import type { ColorModeValue, GradientSpec } from '@src/types/color'; +import type { GradientCanvasAnchor } from '@stores/grid/useGradientEditStore'; // ============================================================================ // 탭 상수 @@ -85,6 +87,13 @@ export interface ColorInputProps { // 외부에서 열림 상태를 제어할 때 사용 isOpen?: boolean; onToggle?: () => void; + /** gradient 편집 지원 — 저장된 상태별 스펙. onModeCommit이 있으면 활성화 */ + gradientValue?: GradientSpec | null; + activeGradientValue?: GradientSpec | null; + /** gradient 지원 커밋 경로 — 단색/그라데이션 확정을 한 콜백으로 수신 */ + onModeCommit?: (state: 'idle' | 'active', value: ColorModeValue) => void; + /** 온캔버스 각도 핸들 앵커 */ + canvasAnchor?: GradientCanvasAnchor; } export interface ToggleSwitchProps { @@ -167,6 +176,8 @@ export interface SingleKeyContentProps { // ============================================================================ export interface StyleTabContentProps { + /** 온캔버스 각도 핸들 앵커 (단일 키/통계) */ + canvasAnchor?: GradientCanvasAnchor; keyIndex: number; keyPosition: KeyPosition; keyCode: string | null; diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index a039fdaf..3e1ee967 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -26,6 +26,8 @@ import GridBackground from './GridBackground'; import SmartGuidesOverlay from '../overlays/SmartGuidesOverlay'; import MarqueeSelectionOverlay from '../overlays/MarqueeSelectionOverlay'; import ResizeHandles from '../handles/ResizeHandles'; +import GradientAxisOverlay from '../handles/GradientAxisHandle'; +import { useGradientEditStore } from '@stores/grid/useGradientEditStore'; import GroupResizeHandles from '../handles/GroupResizeHandles'; import { isElementResizable } from '../handles/groupResizeUtils'; import KeyCounterPreviewLayer from '../layers/KeyCounterPreviewLayer'; @@ -67,6 +69,8 @@ import type { SaveData } from '@hooks/Modal/useUnifiedKeySettingState'; import { resolveImageSource } from '@utils/core/imageSource'; import { DEFAULT_ELEMENT_BG, + DEFAULT_ELEMENT_BORDER, + DEFAULT_ELEMENT_BORDER_WIDTH, DEFAULT_ELEMENT_FONT, DEFAULT_ELEMENT_HAIRLINE, DEFAULT_ELEMENT_RADIUS, @@ -279,6 +283,10 @@ const Grid = ({ }); // 선택 상태 관리 + // 온캔버스 그라데이션 편집 중 여부 — 리사이즈 핸들을 잠시 숨김 + const hasGradientEditSession = useGradientEditStore( + (state) => state.session !== null, + ); const selectedElements = useGridSelectionStore( (state) => state.selectedElements, ); @@ -1404,6 +1412,7 @@ const Grid = ({ transform: `translate3d(${offsetX}px, ${offsetY}px, 0)`, backgroundColor, borderRadius: `${DEFAULT_ELEMENT_RADIUS}px`, + border: `${DEFAULT_ELEMENT_BORDER_WIDTH}px solid ${DEFAULT_ELEMENT_BORDER}`, boxShadow: previewImage ? undefined : DEFAULT_ELEMENT_SHADOW, overflow: 'hidden', opacity: 0.5, @@ -1630,6 +1639,8 @@ const Grid = ({ {/* 선택된 요소 표시 - 그룹 리사이즈 중에는 개별 테두리 숨김 (흔들림 방지) */} {selectedElements.map((el, _idx) => { + // 온캔버스 그라데이션 편집 중에는 선택 테두리 숨김 (축·스톱만 표시) + if (hasGradientEditSession) return null; // 그룹 리사이즈 중에는 개별 요소 테두리 숨김 (스냅으로 인한 흔들림 방지) if (selectedElements.length > 1 && previewElementBounds) { return null; @@ -1808,6 +1819,8 @@ const Grid = ({ if (!bounds || !elementId) return null; + if (hasGradientEditSession) return null; + return ( 1 && ( + {selectedElements.length > 1 && !hasGradientEditSession && ( )} + {/* 온캔버스 그라데이션 각도 핸들 — 피커가 그라데이션 형식일 때만 표시 */} + {/* 우클릭 리스트 팝업 */}
; + statPositions: Record; + graphPositions?: Record; + knobPositions?: Record; + selectedElements: SelectedElement[]; + selectedKeyType: string; + zoom: number; + panX: number; + panY: number; +} + +// 축 끝 회전 앵커 — 시각 점과 히트 영역(px) +const ANCHOR_DOT_SIZE = 7; +const ANCHOR_HIT_SIZE = 18; +// 스톱 표식 — 앵커 점은 선 위, 색 스왓치는 그 바로 위에 붙는 태그 +const STOP_ANCHOR_DOT_SIZE = 5; +const SWATCH_SIZE = 15; +const SWATCH_LIFT = 16; +// 축 선의 드래그 히트 두께(px) — 시각 선은 1.5px, 잡는 영역은 넓게 +const AXIS_HIT_THICKNESS = 12; +// 자석 스냅 판정 각도(도) — 모서리·변 중앙 방향에 이 범위 안이면 흡착 +const MAGNET_THRESHOLD_DEG = 6; +// 클릭이 드래그로 승격되는 이동 임계값(px) +const DRAG_THRESHOLD_PX = 3; + +type AxisEnd = 'start' | 'end'; + +type DragState = + | { + type: 'rotate'; + pointerId: number; + end: AxisEnd; + ownerKey: string; + startSpec: GradientSpec; + moved: boolean; + /** 이동 없이 떼면 그 자리에 스톱 추가 — 축 히트 스트립 한정 */ + addOnClick: boolean; + downX: number; + downY: number; + } + | { + type: 'stop'; + pointerId: number; + index: number; + moved: boolean; + ownerKey: string; + startSpec: GradientSpec; + lastPos: number; + downX: number; + downY: number; + }; + +const normalizeAngle = (deg: number): number => ((deg % 360) + 360) % 360; + +const circularDistance = (a: number, b: number): number => { + const diff = Math.abs(normalizeAngle(a) - normalizeAngle(b)); + return Math.min(diff, 360 - diff); +}; + +// 변 중앙 4방향 + 모서리 4방향 (요소 종횡비 반영) +const buildMagnetAngles = (width: number, height: number): number[] => { + const corner = normalizeAngle( + (Math.atan2(width / 2, height / 2) * 180) / Math.PI, + ); + return [ + 0, + 90, + 180, + 270, + corner, + normalizeAngle(180 - corner), + normalizeAngle(180 + corner), + normalizeAngle(360 - corner), + ]; +}; + +const anchorKeyOf = (anchor: { kind: string; index?: number }): string => + `${anchor.kind}:${anchor.index ?? ''}`; + +const stopAll = (e: React.SyntheticEvent) => { + e.preventDefault(); + e.stopPropagation(); +}; + +const GradientAxisOverlay = ({ + positions, + statPositions, + graphPositions, + knobPositions, + selectedElements, + selectedKeyType, + zoom, + panX, + panY, +}: GradientAxisOverlayProps) => { + const { t } = useTranslation(); + const session = useGradientEditStore((state) => state.session); + const dragRef = useRef(null); + const [dragAngle, setDragAngle] = useState(null); + const [dragStop, setDragStop] = useState<{ + index: number; + pos: number; + } | null>(null); + const rootRef = useRef(null); + // window 드래그 핸들러가 리렌더와 무관하게 최신 값을 읽도록 ref로 노출 + const sessionRef = useRef(session); + // eslint-disable-next-line react-hooks/refs + sessionRef.current = session; + const geoRef = useRef<{ + cx: number; + cy: number; + halfLine: number; + dirX: number; + dirY: number; + magnetAngles: number[]; + worldW: number; + worldH: number; + zoom: number; + } | null>(null); + // 드래그 세션 해제자 — begin에서 등록, 종료 경로 어디서든 1회 실행 + const detachRef = useRef<(() => void) | null>(null); + + // 드래그 중 언마운트(피커 닫힘 등)에도 window 리스너 정리 + useEffect(() => () => detachRef.current?.(), []); + + if (!session) return null; + + // 앵커 → 월드 bounds 해석 + const resolveBounds = (): Bounds | null => { + const { anchor } = session; + if (anchor.kind === 'batch') { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const el of selectedElements) { + if (el.index === undefined) continue; + const pos = + el.type === 'key' + ? positions[selectedKeyType]?.[el.index] + : el.type === 'stat' + ? statPositions[selectedKeyType]?.[el.index] + : el.type === 'graph' + ? graphPositions?.[selectedKeyType]?.[el.index] + : el.type === 'knob' + ? knobPositions?.[selectedKeyType]?.[el.index] + : undefined; + if (!pos) continue; + const w = pos.width || (el.type === 'graph' ? 200 : 60); + const h = pos.height || (el.type === 'graph' ? 100 : 60); + minX = Math.min(minX, pos.dx); + minY = Math.min(minY, pos.dy); + maxX = Math.max(maxX, pos.dx + w); + maxY = Math.max(maxY, pos.dy + h); + } + if (!Number.isFinite(minX)) return null; + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + + const pos = + anchor.kind === 'key' + ? positions[selectedKeyType]?.[anchor.index] + : anchor.kind === 'stat' + ? statPositions[selectedKeyType]?.[anchor.index] + : anchor.kind === 'graph' + ? graphPositions?.[selectedKeyType]?.[anchor.index] + : knobPositions?.[selectedKeyType]?.[anchor.index]; + if (!pos) return null; + return { + x: pos.dx, + y: pos.dy, + width: pos.width || (anchor.kind === 'graph' ? 200 : 60), + height: pos.height || (anchor.kind === 'graph' ? 100 : 60), + }; + }; + + const bounds = resolveBounds(); + if (!bounds) return null; + + const cx = (bounds.x + bounds.width / 2) * zoom + panX; + const cy = (bounds.y + bounds.height / 2) * zoom + panY; + const magnetAngles = buildMagnetAngles(bounds.width, bounds.height); + + const angle = dragAngle ?? session.spec.angle; + const rad = (angle * Math.PI) / 180; + // CSS linear-gradient: 0deg = 위, 시계 방향 — 화면 좌표(y 아래)로 변환 + const dirX = Math.sin(rad); + const dirY = -Math.cos(rad); + // CSS 그라데이션 라인 절반 길이 — pos 0/1이 이 지점에 해당 + const halfLine = + ((Math.abs(bounds.width * Math.sin(rad)) + + Math.abs(bounds.height * Math.cos(rad))) / + 2) * + zoom; + const endX = cx + dirX * halfLine; + const endY = cy + dirY * halfLine; + // pos(0~1) → 축 위 화면 좌표 + const stopPoint = (pos: number) => ({ + x: cx + dirX * (pos - 0.5) * 2 * halfLine, + y: cy + dirY * (pos - 0.5) * 2 * halfLine, + }); + + // eslint-disable-next-line react-hooks/refs + geoRef.current = { + cx, + cy, + halfLine, + dirX, + dirY, + magnetAngles, + worldW: bounds.width, + worldH: bounds.height, + zoom, + }; + + // 화면(client) 기준 중심 — 드래그 중 휠 팬·줌으로 좌표가 움직여도 + // 매 이벤트에서 최신 지오메트리로 재계산한다 + const clientOrigin = () => { + const hostRect = rootRef.current?.getBoundingClientRect(); + const geo = geoRef.current; + return { + x: (hostRect?.left ?? 0) + (geo?.cx ?? 0), + y: (hostRect?.top ?? 0) + (geo?.cy ?? 0), + }; + }; + + const angleFromClient = ( + clientX: number, + clientY: number, + end: AxisEnd, + magnetDisabled: boolean, + ): number => { + const origin = clientOrigin(); + const raw = + (Math.atan2(clientX - origin.x, origin.y - clientY) * 180) / Math.PI; + // 시작점 쪽을 잡으면 축 반대 방향이 그라데이션 진행 방향 + let next = normalizeAngle(Math.round(end === 'start' ? raw + 180 : raw)); + if (!magnetDisabled) { + for (const magnet of geoRef.current?.magnetAngles ?? []) { + if (circularDistance(next, magnet) <= MAGNET_THRESHOLD_DEG) { + next = magnet; + break; + } + } + } + return normalizeAngle(Math.round(next)); + }; + + // 포인터를 축에 사영해 pos(0~1) 계산 + const posFromClient = (clientX: number, clientY: number): number => { + const origin = clientOrigin(); + const geo = geoRef.current; + if (!geo || geo.halfLine === 0) return 0.5; + const dx = clientX - origin.x; + const dy = clientY - origin.y; + const projected = + (dx * geo.dirX + dy * geo.dirY) / (2 * geo.halfLine) + 0.5; + return Math.min(1, Math.max(0, projected)); + }; + + const currentSpec = () => sessionRef.current?.spec ?? session.spec; + + const stopsWithMovedIndex = (index: number, pos: number) => + currentSpec().stops.map((s, i) => (i === index ? { ...s, pos } : s)); + + const commitStopDrag = (index: number, pos: number) => { + const live = sessionRef.current; + if (!live) return; + const stops = stopsWithMovedIndex(index, pos); + const next = toCanonicalGradient({ ...currentSpec(), stops }); + // canonical과 동일한 안정 정렬을 원본 인덱스 태그로 재현해 선택 재매핑 + const sortedIndexes = stops + .map((s, i) => ({ i, pos: Math.min(1, Math.max(0, s.pos)) })) + .sort((a, b) => a.pos - b.pos); + const newIndex = sortedIndexes.findIndex((s) => s.i === index); + if (newIndex >= 0) live.selectStop(newIndex); + live.apply(next, true); + }; + + // 축 클릭 스톱 추가 — 색은 선택 스톱 기준 (피커 스톱 바와 동일 규칙) + const addStopAt = (pos: number) => { + const live = sessionRef.current; + if (!live) return; + const spec = currentSpec(); + if (spec.stops.length >= GRADIENT_STOPS_MAX) return; + const color = + spec.stops[live.selectedIndex]?.color ?? + spec.stops[0]?.color ?? + '#ffffff'; + const next = toCanonicalGradient({ + ...spec, + stops: [...spec.stops, { color, pos }], + }); + const newIndex = next.stops.findIndex( + (s) => s.pos === pos && s.color === color, + ); + live.selectStop(newIndex >= 0 ? newIndex : next.stops.length - 1); + live.apply(next, true); + }; + + // 드래그 소유 세션 검증 — 세션이 사라지거나 다른 대상으로 바뀌면 중단 + const ownedDrag = (e: PointerEvent): DragState | null => { + const drag = dragRef.current; + if (!drag || e.pointerId !== drag.pointerId) return null; + const live = sessionRef.current; + if (!live || drag.ownerKey !== anchorKeyOf(live.anchor)) { + detachRef.current?.(); + setDragAngle(null); + setDragStop(null); + return null; + } + return drag; + }; + + const handleWindowMove = (e: PointerEvent) => { + const drag = ownedDrag(e); + if (!drag) return; + // 창 밖에서 버튼이 이미 떼졌으면 stale 드래그 — 커밋 없이 종료 + if (e.buttons === 0) { + cancelActiveDrag(); + return; + } + const live = sessionRef.current; + if (!live) return; + if (!drag.moved) { + if ( + Math.hypot(e.clientX - drag.downX, e.clientY - drag.downY) < + DRAG_THRESHOLD_PX + ) { + return; + } + drag.moved = true; + } + + if (drag.type === 'rotate') { + const next = angleFromClient( + e.clientX, + e.clientY, + drag.end, + e.ctrlKey || e.metaKey, + ); + setDragAngle(next); + live.apply({ ...currentSpec(), angle: next }, false); + return; + } + + // 스톱은 항상 현재 축에 사영 — 위치만 이동, 각도 불변 + const pos = posFromClient(e.clientX, e.clientY); + drag.lastPos = pos; + setDragStop({ index: drag.index, pos }); + live.apply( + { ...currentSpec(), stops: stopsWithMovedIndex(drag.index, pos) }, + false, + ); + }; + + const handleWindowUp = (e: PointerEvent) => { + const drag = ownedDrag(e); + if (!drag) return; + detachRef.current?.(); + setDragAngle(null); + setDragStop(null); + const live = sessionRef.current; + if (!live) return; + if (drag.type === 'rotate') { + if (!drag.moved) { + // 클릭 — 축 히트 스트립이면 그 위치에 스톱 추가 + if (drag.addOnClick) addStopAt(posFromClient(e.clientX, e.clientY)); + return; + } + const finalAngle = angleFromClient( + e.clientX, + e.clientY, + drag.end, + e.ctrlKey || e.metaKey, + ); + live.apply( + toCanonicalGradient({ ...currentSpec(), angle: finalAngle }), + true, + ); + return; + } + if (!drag.moved) return; // 클릭 — 선택만 + commitStopDrag(drag.index, drag.lastPos); + }; + + // 취소 — preview로 반영된 변경을 시작 시점 spec으로 복원 + const cancelActiveDrag = () => { + const drag = dragRef.current; + if (!drag) return; + detachRef.current?.(); + setDragAngle(null); + setDragStop(null); + const live = sessionRef.current; + if (live && drag.ownerKey === anchorKeyOf(live.anchor)) { + live.apply(drag.startSpec, false); + } + }; + + const handleWindowCancel = (e: PointerEvent) => { + const drag = dragRef.current; + if (!drag || e.pointerId !== drag.pointerId) return; + cancelActiveDrag(); + }; + + // 창 포커스 상실 — pointerup이 오지 않으므로 유령 드래그 방지 취소 + const handleWindowBlur = () => cancelActiveDrag(); + + // 드래그 제스처의 후속 click이 그리드 선택 해제로 새지 않게 1회 억제 + const suppressNextClick = () => { + const swallow = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + window.removeEventListener('click', swallow, true); + }; + window.addEventListener('click', swallow, true); + // click이 아예 안 오는 경로(cancel 등) 대비 — 다음 틱에 정리 + setTimeout(() => window.removeEventListener('click', swallow, true), 0); + }; + + const attachWindowDrag = () => { + window.addEventListener('pointermove', handleWindowMove); + window.addEventListener('pointerup', handleWindowUp); + window.addEventListener('pointercancel', handleWindowCancel); + window.addEventListener('blur', handleWindowBlur); + detachRef.current = () => { + window.removeEventListener('pointermove', handleWindowMove); + window.removeEventListener('pointerup', handleWindowUp); + window.removeEventListener('pointercancel', handleWindowCancel); + window.removeEventListener('blur', handleWindowBlur); + detachRef.current = null; + dragRef.current = null; + suppressNextClick(); + }; + }; + + const beginRotateDrag = ( + e: React.PointerEvent, + end: AxisEnd, + addOnClick: boolean, + ) => { + if (e.button !== 0) return; + stopAll(e); + detachRef.current?.(); + dragRef.current = { + type: 'rotate', + pointerId: e.pointerId, + end, + ownerKey: anchorKeyOf(session.anchor), + startSpec: session.spec, + moved: false, + addOnClick, + downX: e.clientX, + downY: e.clientY, + }; + attachWindowDrag(); + }; + + // 축 히트 스트립 — 잡은 지점이 축의 어느 절반인지로 회전 기준 방향 결정 + const beginStripPointer = (e: React.PointerEvent) => { + const origin = clientOrigin(); + const geo = geoRef.current; + const along = + (e.clientX - origin.x) * (geo?.dirX ?? 0) + + (e.clientY - origin.y) * (geo?.dirY ?? 0); + beginRotateDrag(e, along >= 0 ? 'end' : 'start', true); + }; + + const beginAnchorRotate = + (end: AxisEnd) => (e: React.PointerEvent) => + beginRotateDrag(e, end, false); + + const beginStopDrag = + (index: number) => (e: React.PointerEvent) => { + if (e.button !== 0) return; + stopAll(e); + detachRef.current?.(); + session.selectStop(index); + dragRef.current = { + type: 'stop', + pointerId: e.pointerId, + index, + moved: false, + ownerKey: anchorKeyOf(session.anchor), + startSpec: session.spec, + lastPos: session.spec.stops[index]?.pos ?? 0.5, + downX: e.clientX, + downY: e.clientY, + }; + attachWindowDrag(); + }; + + // 키보드 각도 조절 — 화살표 ±1°, Shift ±15° (슬라이더 시맨틱) + const handleRotateKeyDown = (e: React.KeyboardEvent) => { + let delta = 0; + if (e.key === 'ArrowRight' || e.key === 'ArrowUp') delta = 1; + else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') delta = -1; + else return; + e.preventDefault(); + e.stopPropagation(); + const step = e.shiftKey ? 15 : 1; + const next = normalizeAngle(session.spec.angle + delta * step); + session.apply(toCanonicalGradient({ ...session.spec, angle: next }), true); + }; + + // 우클릭 삭제 — 최소 2개 유지 (피커 스톱 바와 동일 규칙) + const handleStopContextMenu = (index: number) => (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (session.spec.stops.length <= 2) return; + const stops = session.spec.stops.filter((_, i) => i !== index); + const nextSelected = + session.selectedIndex > index + ? session.selectedIndex - 1 + : Math.min(session.selectedIndex, stops.length - 1); + session.selectStop(Math.max(0, nextSelected)); + session.apply(toCanonicalGradient({ ...session.spec, stops }), true); + }; + + const isRotating = dragAngle !== null; + const dragStopScreen = dragStop ? stopPoint(dragStop.pos) : null; + + return ( +
+ {/* 축 선 (시각) */} +
+ {/* 축 히트 스트립 — 드래그 = 회전, 클릭 = 스톱 추가, 화살표 = 미세 조절 */} +
+ {/* 축 끝 앵커 — 선 끝점 위 흰 점, 드래그로 각도만 조절 */} + {(['start', 'end'] as AxisEnd[]).map((end) => { + const point = stopPoint(end === 'end' ? 1 : 0); + return ( + + ); + })} + {/* 스톱 — 앵커 점은 선 위, 색 스왓치는 바로 위 태그. 드래그 = 위치, 우클릭 = 삭제 */} + {session.spec.stops.map((stop, i) => { + const point = stopPoint(stop.pos); + const isAxisEnd = stop.pos === 0 || stop.pos === 1; + return ( + + {!isAxisEnd && ( +