diff --git a/src-tauri/src/commands/keys/keys.rs b/src-tauri/src/commands/keys/keys.rs index a22b05b1..67cc9779 100644 --- a/src-tauri/src/commands/keys/keys.rs +++ b/src-tauri/src/commands/keys/keys.rs @@ -22,6 +22,7 @@ pub struct ModeResponse { } #[derive(Serialize)] +#[serde(rename_all = "camelCase")] pub struct ResetAllResponse { pub keys: KeyMappings, pub positions: KeyPositions, diff --git a/src/renderer/assets/fonts/PretendardVariable.woff2 b/src/renderer/assets/fonts/PretendardVariable.woff2 new file mode 100644 index 00000000..49c54b51 Binary files /dev/null and b/src/renderer/assets/fonts/PretendardVariable.woff2 differ diff --git a/src/renderer/assets/svgs/broom.svg b/src/renderer/assets/svgs/broom.svg index 4db1510e..0dce7c20 100644 --- a/src/renderer/assets/svgs/broom.svg +++ b/src/renderer/assets/svgs/broom.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/bug.svg b/src/renderer/assets/svgs/bug.svg index 4f13e86f..48f995ef 100644 --- a/src/renderer/assets/svgs/bug.svg +++ b/src/renderer/assets/svgs/bug.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/chevron-down.svg b/src/renderer/assets/svgs/chevron-down.svg index 59648cf3..3761507f 100644 --- a/src/renderer/assets/svgs/chevron-down.svg +++ b/src/renderer/assets/svgs/chevron-down.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/close.svg b/src/renderer/assets/svgs/close.svg index d82a8587..33281b19 100644 --- a/src/renderer/assets/svgs/close.svg +++ b/src/renderer/assets/svgs/close.svg @@ -1,4 +1,4 @@ - - + + diff --git a/src/renderer/assets/svgs/close_eye.svg b/src/renderer/assets/svgs/close_eye.svg index d784686f..3daf3aa9 100644 --- a/src/renderer/assets/svgs/close_eye.svg +++ b/src/renderer/assets/svgs/close_eye.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/code.svg b/src/renderer/assets/svgs/code.svg index b81334bd..68d58cad 100644 --- a/src/renderer/assets/svgs/code.svg +++ b/src/renderer/assets/svgs/code.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/eraser.svg b/src/renderer/assets/svgs/eraser.svg index aa4c5160..9def0a71 100644 --- a/src/renderer/assets/svgs/eraser.svg +++ b/src/renderer/assets/svgs/eraser.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/flask.svg b/src/renderer/assets/svgs/flask.svg index be29536f..4d8c40ff 100644 --- a/src/renderer/assets/svgs/flask.svg +++ b/src/renderer/assets/svgs/flask.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/folder.svg b/src/renderer/assets/svgs/folder.svg index f70f16b5..7866487b 100644 --- a/src/renderer/assets/svgs/folder.svg +++ b/src/renderer/assets/svgs/folder.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/github.svg b/src/renderer/assets/svgs/github.svg index 04faf3f1..216ddb86 100644 --- a/src/renderer/assets/svgs/github.svg +++ b/src/renderer/assets/svgs/github.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/grid.svg b/src/renderer/assets/svgs/grid.svg index 7a71b63f..e5bc8dad 100644 --- a/src/renderer/assets/svgs/grid.svg +++ b/src/renderer/assets/svgs/grid.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/layer.svg b/src/renderer/assets/svgs/layer.svg index df4a9a5f..a4767058 100644 --- a/src/renderer/assets/svgs/layer.svg +++ b/src/renderer/assets/svgs/layer.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/logo.svg b/src/renderer/assets/svgs/logo.svg index 96474a09..844d5d3b 100644 --- a/src/renderer/assets/svgs/logo.svg +++ b/src/renderer/assets/svgs/logo.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/minimize.svg b/src/renderer/assets/svgs/minimize.svg index 35721fbd..7f021eeb 100644 --- a/src/renderer/assets/svgs/minimize.svg +++ b/src/renderer/assets/svgs/minimize.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/minus.svg b/src/renderer/assets/svgs/minus.svg index 1a182f2c..5201ca11 100644 --- a/src/renderer/assets/svgs/minus.svg +++ b/src/renderer/assets/svgs/minus.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/move.svg b/src/renderer/assets/svgs/move.svg index 2b4cb018..db2b3bd1 100644 --- a/src/renderer/assets/svgs/move.svg +++ b/src/renderer/assets/svgs/move.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/note.svg b/src/renderer/assets/svgs/note.svg index d46c0bc4..cb2bda5d 100644 --- a/src/renderer/assets/svgs/note.svg +++ b/src/renderer/assets/svgs/note.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/open_eye.svg b/src/renderer/assets/svgs/open_eye.svg index 6728c6ed..214d7cb5 100644 --- a/src/renderer/assets/svgs/open_eye.svg +++ b/src/renderer/assets/svgs/open_eye.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/palette.svg b/src/renderer/assets/svgs/palette.svg index 6be1bb8d..b30a6722 100644 --- a/src/renderer/assets/svgs/palette.svg +++ b/src/renderer/assets/svgs/palette.svg @@ -1,10 +1,10 @@ - + - + diff --git a/src/renderer/assets/svgs/plus.svg b/src/renderer/assets/svgs/plus.svg index 3ebbca08..a3487852 100644 --- a/src/renderer/assets/svgs/plus.svg +++ b/src/renderer/assets/svgs/plus.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/plus2.svg b/src/renderer/assets/svgs/plus2.svg index 3b8e8798..a099a860 100644 --- a/src/renderer/assets/svgs/plus2.svg +++ b/src/renderer/assets/svgs/plus2.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/primary.svg b/src/renderer/assets/svgs/primary.svg index 5e40644c..a1b1cbff 100644 --- a/src/renderer/assets/svgs/primary.svg +++ b/src/renderer/assets/svgs/primary.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/reset.svg b/src/renderer/assets/svgs/reset.svg index ec5aa4a3..375002d6 100644 --- a/src/renderer/assets/svgs/reset.svg +++ b/src/renderer/assets/svgs/reset.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/setting.svg b/src/renderer/assets/svgs/setting.svg index 592a31bb..98b28b41 100644 --- a/src/renderer/assets/svgs/setting.svg +++ b/src/renderer/assets/svgs/setting.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/sparkles.svg b/src/renderer/assets/svgs/sparkles.svg index 44ff4bba..4478636b 100644 --- a/src/renderer/assets/svgs/sparkles.svg +++ b/src/renderer/assets/svgs/sparkles.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/assets/svgs/trash.svg b/src/renderer/assets/svgs/trash.svg index 984ea5fe..9f76305a 100644 --- a/src/renderer/assets/svgs/trash.svg +++ b/src/renderer/assets/svgs/trash.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/src/renderer/assets/svgs/turn_arrow.svg b/src/renderer/assets/svgs/turn_arrow.svg index 1dbd703f..b4fad58b 100644 --- a/src/renderer/assets/svgs/turn_arrow.svg +++ b/src/renderer/assets/svgs/turn_arrow.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index 0d84e9b6..31223632 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useCallback, useEffect, useState, useRef } from 'react'; import { useTranslation } from '@contexts/useTranslation'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { useKeyStore } from '@stores/data/useKeyStore'; @@ -54,6 +54,8 @@ import { useBatchHandlers, usePanelScroll, } from './PropertiesPanel/index'; +import { SIDE_PANEL_FRAME_CLASS } from './PropertiesPanel/panelChrome'; +import { PanelNavProvider } from './PropertiesPanel/PanelNavContext'; import Checkbox from '@components/main/common/Checkbox'; import Dropdown from '@components/main/common/Dropdown'; import type { NoteColor } from '@src/types/key/keys'; @@ -72,6 +74,9 @@ const getStatTypeLabel = (statType?: StatItemType | null): string => { } }; +// 서브 페이지 exit 전환 시간 — --ui-duration-page와 동기 +const PAGE_EXIT_MS = 250; + // ============================================================================ // 메인 컴포넌트 Props // ============================================================================ @@ -374,6 +379,67 @@ const PropertiesPanel: React.FC = ({ // 탭 상태 const [activeTab, setActiveTab] = useState(TABS.STYLE); + // 인-패널 내비게이션 — 피커 서브 페이지 (키는 트리거 사이트별 유니크) + // activePageKey는 애니메이션 상태, renderPageKey는 마운트 상태 — + // exit 전환이 끝날 때까지 콘텐츠를 유지해 빈 페이지 슬라이드 방지 + const [activePageKey, setActivePageKey] = useState(null); + const [renderPageKey, setRenderPageKey] = useState(null); + const [pageHost, setPageHost] = useState(null); + const pageExitTimerRef = useRef(null); + + const openPage = useCallback((key: string) => { + if (pageExitTimerRef.current !== null) { + window.clearTimeout(pageExitTimerRef.current); + pageExitTimerRef.current = null; + } + setActivePageKey(key); + setRenderPageKey(key); + }, []); + + const closePage = useCallback(() => { + setActivePageKey(null); + if (pageExitTimerRef.current !== null) { + window.clearTimeout(pageExitTimerRef.current); + } + pageExitTimerRef.current = window.setTimeout(() => { + pageExitTimerRef.current = null; + setRenderPageKey(null); + }, PAGE_EXIT_MS); + }, []); + + useEffect(() => { + return () => { + if (pageExitTimerRef.current !== null) { + window.clearTimeout(pageExitTimerRef.current); + } + }; + }, []); + + // 탭/모드/패널 표시 전환 시 서브 페이지 닫기 + useEffect(() => { + closePage(); + }, [activeTab, panelMode, isPanelVisible, selectedKeyType, closePage]); + + // Escape로 서브 페이지 닫기 — 입력 필드 편집과 경합 방지 + useEffect(() => { + if (!activePageKey) return; + const onKey = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + const target = event.target; + if ( + target instanceof HTMLElement && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable) + ) { + return; + } + closePage(); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [activePageKey, closePage]); + // 통계 요소 선택 시 NOTE 탭 숨김 처리 useEffect(() => { if ( @@ -803,6 +869,7 @@ const PropertiesPanel: React.FC = ({ setShowGraphImagePicker(false); setShowBatchImagePicker(false); setIsListening(false); + closePage(); }, [ singleKeyIndex, selectedKeyElements.length, @@ -810,6 +877,7 @@ const PropertiesPanel: React.FC = ({ isPanelVisible, pluginSettingsPanel, setIsPanelVisible, + closePage, ]); // 언마운트 시 키보드 플래그 오염 방지 @@ -2099,7 +2167,7 @@ const PropertiesPanel: React.FC = ({ ) => { if (!schema || Object.keys(schema).length === 0) { return ( -

+

{t('propertiesPanel.pluginNoSettings') || '설정할 항목이 없습니다.'}

); @@ -2247,9 +2315,9 @@ const PropertiesPanel: React.FC = ({ return (
-

{labelText}

+

{labelText}

{control}
); @@ -2552,7 +2620,7 @@ const PropertiesPanel: React.FC = ({
-
+
diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx index e6ce598e..cc6c6368 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx @@ -1,5 +1,6 @@ /* eslint-disable react-hooks/set-state-in-effect */ import React, { useState, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; import type { PropertyRowProps, NumberInputProps, @@ -23,9 +24,18 @@ export const PropertyRow: React.FC = ({ label, children, }) => ( -
-

{label}

-
{children}
+
+

{label}

+
{children}
+
+); + +// 그룹 카드 — 관련 속성 행을 하나의 면으로 묶는 섹션 컨테이너 +export const PropertySection: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => ( +
+ {children}
); @@ -238,12 +248,12 @@ export const NumberInput: React.FC = ({ onFocus={handleFocus} onBlur={handleBlur} placeholder={showMixedPlaceholder ? mixedPlaceholder : undefined} - className={`text-center h-[23px] bg-[#2A2A30] rounded-[7px] border-[1px] ${ - isFocused ? 'border-[#459BF8]' : 'border-[#3A3943]' - } text-style-4 ${ + className={`text-center h-[23px] bg-inset rounded-md ${ + isFocused ? 'shadow-focus-ring' : '' + } text-body tabular-nums ${ showMixedPlaceholder - ? 'text-[#6B6D75] italic placeholder:text-[#6B6D75] placeholder:italic' - : 'text-[#DBDEE8]' + ? 'text-fg-faint italic placeholder:text-fg-faint placeholder:italic' + : 'text-fg' }`} style={{ width }} /> @@ -252,13 +262,13 @@ export const NumberInput: React.FC = ({ return (
{prefix && !showMixedPlaceholder && ( - + {prefix} )} @@ -275,10 +285,10 @@ export const NumberInput: React.FC = ({ prefix && !showMixedPlaceholder ? 'left-[20px]' : 'left-0' } top-[-1px] h-[23px] ${ prefix && !showMixedPlaceholder ? 'w-[26px]' : 'w-full' - } bg-transparent text-style-4 ${ + } bg-transparent text-body tabular-nums ${ showMixedPlaceholder - ? 'text-[#6B6D75] italic placeholder:text-[#6B6D75] placeholder:italic' - : 'text-[#DBDEE8]' + ? 'text-fg-faint italic placeholder:text-fg-faint placeholder:italic' + : 'text-fg' } text-center`} />
@@ -470,22 +480,20 @@ export const OptionalNumberInput: React.FC = ({ : placeholder; const placeholderClass = effectivePlaceholder - ? 'placeholder:text-[#6B6D75] placeholder:italic' + ? 'placeholder:text-fg-faint placeholder:italic' : ''; - const textClass = showMixedPlaceholder - ? 'text-[#6B6D75] italic' - : 'text-[#DBDEE8]'; + const textClass = showMixedPlaceholder ? 'text-fg-faint italic' : 'text-fg'; if (prefix) { return (
{!showMixedPlaceholder && ( - + {prefix} )} @@ -502,7 +510,7 @@ export const OptionalNumberInput: React.FC = ({ !showMixedPlaceholder ? 'left-[20px]' : 'left-0' } top-[-1px] h-[23px] ${ !showMixedPlaceholder ? 'w-[26px]' : 'w-full' - } bg-transparent text-style-4 ${textClass} ${placeholderClass} text-center`} + } bg-transparent text-body tabular-nums ${textClass} ${placeholderClass} text-center`} />
); @@ -519,9 +527,9 @@ export const OptionalNumberInput: React.FC = ({ onFocus={handleFocus} onBlur={handleBlur} placeholder={effectivePlaceholder} - className={`text-center h-[23px] bg-[#2A2A30] rounded-[7px] border-[1px] ${ - isFocused ? 'border-[#459BF8]' : 'border-[#3A3943]' - } text-style-4 ${textClass} ${placeholderClass}`} + className={`text-center h-[23px] bg-inset rounded-md ${ + isFocused ? 'shadow-focus-ring' : '' + } text-body tabular-nums ${textClass} ${placeholderClass}`} style={{ width }} /> ); @@ -537,9 +545,9 @@ export const OptionalNumberInput: React.FC = ({ onFocus={handleFocus} onBlur={handleBlur} placeholder={effectivePlaceholder} - className={`text-center h-[23px] bg-[#2A2A30] rounded-[7px] border-[1px] ${ - isFocused ? 'border-[#459BF8]' : 'border-[#3A3943]' - } text-style-4 ${textClass} ${placeholderClass}`} + className={`text-center h-[23px] bg-inset rounded-md ${ + isFocused ? 'shadow-focus-ring' : '' + } text-body tabular-nums ${textClass} ${placeholderClass}`} style={{ width }} /> ); @@ -584,12 +592,12 @@ export const TextInput: React.FC = ({ onFocus={() => setIsFocused(true)} onBlur={handleBlur} placeholder={placeholder} - className={`text-center h-[23px] p-[6px] bg-[#2A2A30] rounded-[7px] border-[1px] ${ - isFocused ? 'border-[#459BF8]' : 'border-[#3A3943]' - } text-style-4 ${ + className={`text-center h-[23px] p-[6px] bg-inset rounded-md ${ + isFocused ? 'shadow-focus-ring' : '' + } text-body tabular-nums ${ isMixed - ? 'text-[#DBDEE8] placeholder:text-[#6B6D75] placeholder:italic' - : 'text-[#DBDEE8]' + ? 'text-fg placeholder:text-fg-faint placeholder:italic' + : 'text-fg' }`} style={{ width }} /> @@ -718,8 +726,8 @@ export const ColorInput: React.FC = ({ - {isOpen && ( - <> -
setIsOpen(false)} - /> -
- {options.map((opt) => ( - - ))} -
- - )} + {isOpen && + menuPos && + createPortal( + // 패널의 backdrop-filter·mask 아래에서는 중첩 backdrop-blur가 + // 무력화되므로 메뉴는 body로 포털해 backdrop root 밖에서 그린다 + <> +
setIsOpen(false)} + /> +
+ {options.map((opt) => ( + + ))} +
+ , + document.body, + )}
); }; @@ -830,7 +893,7 @@ export const ToggleSwitch: React.FC = ({ - + {/* 외곽선 색상 */} + + + - {/* 폰트 스타일 */} - - = 700} - isItalic={batchCounterSettings.fontItalic ?? false} - isUnderline={batchCounterSettings.fontUnderline ?? false} - isStrikethrough={batchCounterSettings.fontStrikethrough ?? false} - onBoldChange={(value) => - handleBatchCounterUpdate({ fontWeight: value ? 700 : 400 }) - } - onItalicChange={(value) => - handleBatchCounterUpdate({ fontItalic: value }) - } - onUnderlineChange={(value) => - handleBatchCounterUpdate({ fontUnderline: value }) - } - onStrikethroughChange={(value) => - handleBatchCounterUpdate({ fontStrikethrough: value }) - } - /> - + {/* 폰트 크기 */} + + handleBatchCounterUpdate({ fontSize: value })} + suffix="px" + min={8} + max={72} + width="54px" + /> + - + {/* 폰트 스타일 */} + + = 700} + isItalic={batchCounterSettings.fontItalic ?? false} + isUnderline={batchCounterSettings.fontUnderline ?? false} + isStrikethrough={batchCounterSettings.fontStrikethrough ?? false} + onBoldChange={(value) => + handleBatchCounterUpdate({ fontWeight: value ? 700 : 400 }) + } + onItalicChange={(value) => + handleBatchCounterUpdate({ fontItalic: value }) + } + onUnderlineChange={(value) => + handleBatchCounterUpdate({ fontUnderline: value }) + } + onStrikethroughChange={(value) => + handleBatchCounterUpdate({ fontStrikethrough: value }) + } + /> + + - {/* 카운터 애니메이션 */} -
-

- {t('counterSetting.animationEnabled') || '카운터 애니메이션'} -

- - handleBatchCounterUpdate({ - animation: { - ...batchCounterSettings.animation, - enabled: !batchCounterSettings.animation.enabled, - }, - }) - } - /> -
+ + {/* 카운터 애니메이션 */} +
+

+ {t('counterSetting.animationEnabled') || '카운터 애니메이션'} +

+ + handleBatchCounterUpdate({ + animation: { + ...batchCounterSettings.animation, + enabled: !batchCounterSettings.animation.enabled, + }, + }) + } + /> +
- - - + + + +
- {/* FontPicker */} - {showFontPicker && ( - { - handleBatchCounterUpdate({ fontFamily }); - }} - onClose={() => setShowFontPicker(false)} - interactiveRefs={[fontButtonRef]} - /> - )} + {/* FontPicker — 패널 서브 페이지 */} + {renderPageKey === FONT_PAGE_KEY && + pageHost && + createPortal( + { + handleBatchCounterUpdate({ fontFamily }); + }} + onClose={closePage} + renderMode="page" + pageTitle={t('counterSetting.font') || '폰트'} + onBack={closePage} + />, + pageHost, + )} - {showAnimationPicker && ( - setShowAnimationPicker(false)} - t={t} - interactiveRefs={[animationButtonRef]} - /> - )} + {/* CounterAnimationPicker — 패널 서브 페이지 */} + {renderPageKey === ANIMATION_PAGE_KEY && + pageHost && + createPortal( + , + pageHost, + )} ); }; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchNoteTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchNoteTabContent.tsx index 94374c70..42ae5426 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchNoteTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchNoteTabContent.tsx @@ -4,7 +4,7 @@ import { PropertyRow, NumberInput, OptionalNumberInput, - SectionDivider, + PropertySection, } from '../index'; import Checkbox from '@components/main/common/Checkbox'; import Dropdown from '@components/main/common/Dropdown'; @@ -78,347 +78,352 @@ const BatchNoteTabContent: React.FC = ({ return ( <> - {/* 노트 효과 표시 */} -
-

- {t('keySetting.noteEffectEnabled') || '노트 효과 표시'} -

- pos.noteEffectEnabled, true).value} - onChange={() => { - const currentValue = getMixedValue( - (pos) => pos.noteEffectEnabled, - true, - ).value; - handleBatchStyleChangeComplete('noteEffectEnabled', !currentValue); - }} - /> -
- - {/* Y축 자동 보정 */} -
-

- {t('keySetting.noteAutoYCorrection') || 'Y축 자동 보정'} -

- pos.noteAutoYCorrection, true).value} - onChange={() => { - const currentValue = getMixedValue( - (pos) => pos.noteAutoYCorrection, - true, - ).value; - handleBatchStyleChangeComplete( - 'noteAutoYCorrection', - !currentValue, - ); - }} - /> -
- - - - {/* 오프셋 */} - - pos.noteOffsetX, 0).value || undefined} - onChange={(value) => - handleBatchStyleChangeComplete('noteOffsetX', value) - } - prefix="X" - allowNegative - allowDecimal - decimalScale={1} - min={NOTE_SETTINGS_CONSTRAINTS.noteOffsetX.min} - max={NOTE_SETTINGS_CONSTRAINTS.noteOffsetX.max} - placeholder="0" - isMixed={getMixedValue((pos) => pos.noteOffsetX, 0).isMixed} - /> - pos.noteOffsetY, 0).value || undefined} - onChange={(value) => - handleBatchStyleChangeComplete('noteOffsetY', value) - } - prefix="Y" - allowNegative - allowDecimal - decimalScale={1} - min={NOTE_SETTINGS_CONSTRAINTS.noteOffsetY.min} - max={NOTE_SETTINGS_CONSTRAINTS.noteOffsetY.max} - placeholder="0" - isMixed={getMixedValue((pos) => pos.noteOffsetY, 0).isMixed} - /> - - - {/* 노트 넓이 */} - - - handleBatchStyleChangeComplete('noteWidth', value) - } - suffix="px" - min={1} - allowDecimal - decimalScale={1} - placeholder="Auto" - isMixed={noteWidthMixed.isMixed} - /> - - - {/* 노트 정렬 */} - - {getMixedValue((pos) => pos.noteAlignment, 'center').isMixed && ( - Mixed - )} - pos.noteAlignment, 'center').value} - onChange={(value) => - handleBatchStyleChangeComplete( - 'noteAlignment', - value as 'left' | 'center' | 'right', - ) - } - /> - + + {/* 노트 효과 표시 */} +
+

+ {t('keySetting.noteEffectEnabled') || '노트 효과 표시'} +

+ pos.noteEffectEnabled, true).value} + onChange={() => { + const currentValue = getMixedValue( + (pos) => pos.noteEffectEnabled, + true, + ).value; + handleBatchStyleChangeComplete( + 'noteEffectEnabled', + !currentValue, + ); + }} + /> +
- + {/* Y축 자동 보정 */} +
+

+ {t('keySetting.noteAutoYCorrection') || 'Y축 자동 보정'} +

+ pos.noteAutoYCorrection, true).value + } + onChange={() => { + const currentValue = getMixedValue( + (pos) => pos.noteAutoYCorrection, + true, + ).value; + handleBatchStyleChangeComplete( + 'noteAutoYCorrection', + !currentValue, + ); + }} + /> +
+
- {/* 노트 색상 */} - -
- + + - {/* 테두리 두께 */} - - pos.noteBorderWidth, - NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.default, - ).value - } - onChange={(value) => - handleBatchStyleChangeComplete('noteBorderWidth', value) - } - suffix="px" - min={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.min} - max={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.max} - allowDecimal - decimalScale={1} - isMixed={ - getMixedValue( - (pos) => pos.noteBorderWidth, - NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.default, - ).isMixed - } - /> - + + {/* 노트 색상 */} + +
+ - + {/* 테두리 두께 */} + + pos.noteBorderWidth, + NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.default, + ).value + } + onChange={(value) => + handleBatchStyleChangeComplete('noteBorderWidth', value) + } + suffix="px" + min={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.min} + max={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.max} + allowDecimal + decimalScale={1} + isMixed={ + getMixedValue( + (pos) => pos.noteBorderWidth, + NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.default, + ).isMixed + } + /> + - {/* 글로우 효과 */} -
-

- {t('keySetting.noteGlow') || '글로우 효과'} -

- pos.noteGlowEnabled, false).value} - onChange={() => { - const currentValue = getMixedValue( - (pos) => pos.noteGlowEnabled, - false, - ).value; - handleBatchStyleChangeComplete('noteGlowEnabled', !currentValue); - }} - /> -
+ {/* 노트 라운딩 */} + + pos.noteBorderRadius, + NOTE_SETTINGS_CONSTRAINTS.borderRadius.default, + ).value + } + onChange={(value) => + handleBatchStyleChangeComplete('noteBorderRadius', value) + } + suffix="px" + min={NOTE_SETTINGS_CONSTRAINTS.borderRadius.min} + max={NOTE_SETTINGS_CONSTRAINTS.borderRadius.max} + allowDecimal + decimalScale={1} + isMixed={ + getMixedValue( + (pos) => pos.noteBorderRadius, + NOTE_SETTINGS_CONSTRAINTS.borderRadius.default, + ).isMixed + } + /> + + - {/* 글로우 색상 */} - -
) ) : ( - + {t('propertiesPanel.multiSelection') || '다중 선택'} )} {!selectedGroupInfo && ( - + ({selectedBatchStyleElements.length}) )} @@ -587,14 +585,14 @@ export const BatchKeyLikePanel: React.FC = ({
) ) : ( - + {t('propertiesPanel.multiSelection') || '다중 선택'} )} {!selectedGroupInfo && ( - + ({selectedGraphElements.length}) )} @@ -1167,14 +1161,14 @@ export const BatchGraphOnlyPanel: React.FC = ({
) ) : ( - + {t('propertiesPanel.multiSelection') || '다중 선택'} )} {!selectedGroupInfo && ( - + ({selectedKnobElements.length}) )} @@ -1535,14 +1526,14 @@ export const BatchKnobOnlyPanel: React.FC = ({
- - + + {/* 정렬 */} + +
+ {/* 수평 정렬 */} +
+ + + +
+ {/* 수직 정렬 */} +
+ + + +
- {/* 수직 정렬 */} -
+ + + {/* 분배 */} + +
-
-
-
+ - {/* 분배 */} - -
- - -
-
- - {/* 간격 */} - - - - - {/* 크기 */} - - pos.width, 60).value} - onChange={(value) => handleBatchResize('width', value)} - prefix="W" - min={10} - max={500} - allowDecimal - decimalScale={1} - isMixed={getMixedValue((pos) => pos.width, 60).isMixed} - /> - pos.height, 60).value} - onChange={(value) => handleBatchResize('height', value)} - prefix="H" - min={10} - max={500} - allowDecimal - decimalScale={1} - isMixed={getMixedValue((pos) => pos.height, 60).isMixed} - /> - + {/* 간격 */} + + + - {afterSizeContent ? ( - <> - - {afterSizeContent} - - - ) : ( - - )} + {/* 크기 */} + + pos.width, 60).value} + onChange={(value) => handleBatchResize('width', value)} + prefix="W" + min={10} + max={500} + allowDecimal + decimalScale={1} + isMixed={getMixedValue((pos) => pos.width, 60).isMixed} + /> + pos.height, 60).value} + onChange={(value) => handleBatchResize('height', value)} + prefix="H" + min={10} + max={500} + allowDecimal + decimalScale={1} + isMixed={getMixedValue((pos) => pos.height, 60).isMixed} + /> + +
- {/* 배경색 */} - - {( - colorState === 'active' - ? getMixedValue( - (pos) => pos.activeBackgroundColor ?? pos.backgroundColor, - 'rgba(121, 121, 121, 0.9)', - ).isMixed - : getMixedValue( + {afterSizeContent ? ( + {afterSizeContent} + ) : null} + + + {/* 배경색 */} + + {( + 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)', + ).isMixed + ) ? ( + Mixed + ) : null} + pos.backgroundColor, 'rgba(46, 46, 47, 0.9)', - ).isMixed - ) ? ( - Mixed - ) : null} - pos.backgroundColor, 'rgba(46, 46, 47, 0.9)') - .value - } - activeValue={ - getMixedValue( - (pos) => pos.activeBackgroundColor ?? pos.backgroundColor, - 'rgba(121, 121, 121, 0.9)', - ).value - } - showStateTabs - stateMode={colorState} - onStateModeChange={setColorState} - onChange={(color) => handleBatchStyleChange('backgroundColor', color)} - onChangeComplete={(color) => - handleBatchStyleChangeComplete('backgroundColor', color) - } - onActiveChange={(color) => - handleBatchStyleChange('activeBackgroundColor', color) - } - onActiveChangeComplete={(color) => - handleBatchStyleChangeComplete('activeBackgroundColor', color) - } - panelElement={panelElement} - /> - - - {/* 테두리 색상 */} - - {( - colorState === 'active' - ? getMixedValue( - (pos) => pos.activeBorderColor ?? pos.borderColor, - 'rgba(255, 255, 255, 0.9)', - ).isMixed - : getMixedValue( + ).value + } + activeValue={ + getMixedValue( + (pos) => pos.activeBackgroundColor ?? pos.backgroundColor, + 'rgba(121, 121, 121, 0.9)', + ).value + } + showStateTabs + stateMode={colorState} + onStateModeChange={setColorState} + onChange={(color) => + handleBatchStyleChange('backgroundColor', color) + } + onChangeComplete={(color) => + handleBatchStyleChangeComplete('backgroundColor', color) + } + onActiveChange={(color) => + handleBatchStyleChange('activeBackgroundColor', color) + } + onActiveChangeComplete={(color) => + handleBatchStyleChangeComplete('activeBackgroundColor', color) + } + panelElement={panelElement} + /> + + + {/* 테두리 색상 */} + + {( + 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)', + ).isMixed + ) ? ( + Mixed + ) : null} + pos.borderColor, 'rgba(113, 113, 113, 0.9)', - ).isMixed - ) ? ( - Mixed - ) : null} - pos.borderColor, 'rgba(113, 113, 113, 0.9)') - .value - } - activeValue={ - getMixedValue( - (pos) => pos.activeBorderColor ?? pos.borderColor, - 'rgba(255, 255, 255, 0.9)', - ).value - } - showStateTabs - stateMode={colorState} - onStateModeChange={setColorState} - onChange={(color) => handleBatchStyleChange('borderColor', color)} - onChangeComplete={(color) => - handleBatchStyleChangeComplete('borderColor', color) - } - onActiveChange={(color) => - handleBatchStyleChange('activeBorderColor', color) - } - onActiveChangeComplete={(color) => - handleBatchStyleChangeComplete('activeBorderColor', color) - } - panelElement={panelElement} - /> - - - {/* 테두리 두께 */} - - {getMixedValue((pos) => pos.borderWidth, 3).isMixed ? ( - Mixed - ) : null} - pos.borderWidth, 3).value} - onChange={(value) => - handleBatchStyleChangeComplete('borderWidth', value) - } - suffix="px" - min={0} - max={20} - allowDecimal - decimalScale={1} - /> - - - {/* 모서리 반경 */} - - {getMixedValue((pos) => pos.borderRadius, 10).isMixed ? ( - Mixed - ) : null} - pos.borderRadius, 10).value} - onChange={(value) => - handleBatchStyleChangeComplete('borderRadius', value) - } - suffix="px" - min={0} - max={100} - allowDecimal - decimalScale={1} - /> - - - {/* 커스텀 이미지 */} - - - - - {!hideDisplayText || !hideFontControls ? : null} - - {/* 표시 텍스트 */} - {!hideDisplayText && ( - - {(() => { - const { isMixed, value } = getDisplayTextMixed(); - const displayTextValue = getMixedValue( - (pos) => pos.displayText, - '', - ).value; - // displayText가 직접 설정되어 있으면 그 값을 value에, 아니면 placeholder에 기본값 표시 - return ( - - handleBatchStyleChangeComplete('displayText', v) - } - placeholder={isMixed ? 'Mixed' : value} - width="54px" - isMixed={isMixed} - /> - ); - })()} + ).value + } + activeValue={ + getMixedValue( + (pos) => pos.activeBorderColor ?? pos.borderColor, + 'rgba(255, 255, 255, 0.9)', + ).value + } + showStateTabs + stateMode={colorState} + onStateModeChange={setColorState} + onChange={(color) => handleBatchStyleChange('borderColor', color)} + onChangeComplete={(color) => + handleBatchStyleChangeComplete('borderColor', color) + } + onActiveChange={(color) => + handleBatchStyleChange('activeBorderColor', color) + } + onActiveChangeComplete={(color) => + handleBatchStyleChangeComplete('activeBorderColor', color) + } + panelElement={panelElement} + /> - )} - {!hideFontControls && ( - <> - {/* 폰트 */} - - {getMixedValue((pos) => pos.fontFamily, null).isMixed ? ( - Mixed - ) : null} - + + + + {(!hideDisplayText || !hideFontControls) && ( + + {/* 표시 텍스트 */} + {!hideDisplayText && ( + - {t('propertiesPanel.configure') || '설정하기'} - - + {(() => { + const { isMixed, value } = getDisplayTextMixed(); + const displayTextValue = getMixedValue( + (pos) => pos.displayText, + '', + ).value; + // displayText가 직접 설정되어 있으면 그 값을 value에, 아니면 placeholder에 기본값 표시 + return ( + + handleBatchStyleChangeComplete('displayText', v) + } + placeholder={isMixed ? 'Mixed' : value} + width="54px" + isMixed={isMixed} + /> + ); + })()} + + )} - {/* 글꼴 크기 */} - - {getMixedValue((pos) => pos.fontSize, 14).isMixed ? ( - Mixed - ) : null} - pos.fontSize, 14).value} - onChange={(value) => - handleBatchStyleChangeComplete('fontSize', value) - } - suffix="px" - min={8} - max={72} - allowDecimal - decimalScale={1} - /> - + {!hideFontControls && ( + <> + {/* 폰트 */} + + {getMixedValue((pos) => pos.fontFamily, null).isMixed ? ( + Mixed + ) : null} + + - {/* 글꼴 색상 */} - - {( - colorState === 'active' - ? getMixedValue( - (pos) => pos.activeFontColor ?? pos.fontColor, - '#FFFFFF', - ).isMixed - : getMixedValue( - (pos) => pos.fontColor, - 'rgba(121, 121, 121, 0.9)', - ).isMixed - ) ? ( - Mixed - ) : null} - pos.fontColor, - 'rgba(121, 121, 121, 0.9)', - ).value - } - activeValue={ - getMixedValue( - (pos) => pos.activeFontColor ?? pos.fontColor, - '#FFFFFF', - ).value - } - showStateTabs - stateMode={colorState} - onStateModeChange={setColorState} - onChange={(color) => handleBatchStyleChange('fontColor', color)} - onChangeComplete={(color) => - handleBatchStyleChangeComplete('fontColor', color) - } - onActiveChange={(color) => - handleBatchStyleChange('activeFontColor', color) - } - onActiveChangeComplete={(color) => - handleBatchStyleChangeComplete('activeFontColor', color) - } - panelElement={panelElement} - /> - + {/* 글꼴 크기 */} + + {getMixedValue((pos) => pos.fontSize, 14).isMixed ? ( + Mixed + ) : null} + pos.fontSize, 14).value} + onChange={(value) => + handleBatchStyleChangeComplete('fontSize', value) + } + suffix="px" + min={8} + max={72} + allowDecimal + decimalScale={1} + /> + - {/* 글꼴 스타일 */} - - (pos.fontWeight ?? 700) >= 700, true) - .value - } - isItalic={getMixedValue((pos) => pos.fontItalic, false).value} - isUnderline={ - getMixedValue((pos) => pos.fontUnderline, false).value - } - isStrikethrough={ - getMixedValue((pos) => pos.fontStrikethrough, false).value - } - onBoldChange={(value) => - handleBatchStyleChangeComplete('fontWeight', value ? 700 : 400) - } - onItalicChange={(value) => - handleBatchStyleChangeComplete('fontItalic', value) - } - onUnderlineChange={(value) => - handleBatchStyleChangeComplete('fontUnderline', value) - } - onStrikethroughChange={(value) => - handleBatchStyleChangeComplete('fontStrikethrough', value) - } - /> - - + {/* 글꼴 색상 */} + + {( + colorState === 'active' + ? getMixedValue( + (pos) => pos.activeFontColor ?? pos.fontColor, + '#FFFFFF', + ).isMixed + : getMixedValue( + (pos) => pos.fontColor, + 'rgba(121, 121, 121, 0.9)', + ).isMixed + ) ? ( + Mixed + ) : null} + pos.fontColor, + 'rgba(121, 121, 121, 0.9)', + ).value + } + activeValue={ + getMixedValue( + (pos) => pos.activeFontColor ?? pos.fontColor, + '#FFFFFF', + ).value + } + showStateTabs + stateMode={colorState} + onStateModeChange={setColorState} + onChange={(color) => + handleBatchStyleChange('fontColor', color) + } + onChangeComplete={(color) => + handleBatchStyleChangeComplete('fontColor', color) + } + onActiveChange={(color) => + handleBatchStyleChange('activeFontColor', color) + } + onActiveChangeComplete={(color) => + handleBatchStyleChangeComplete('activeFontColor', color) + } + panelElement={panelElement} + /> + + + {/* 글꼴 스타일 */} + + (pos.fontWeight ?? 700) >= 700, true) + .value + } + isItalic={getMixedValue((pos) => pos.fontItalic, false).value} + isUnderline={ + getMixedValue((pos) => pos.fontUnderline, false).value + } + isStrikethrough={ + getMixedValue((pos) => pos.fontStrikethrough, false).value + } + onBoldChange={(value) => + handleBatchStyleChangeComplete( + 'fontWeight', + value ? 700 : 400, + ) + } + onItalicChange={(value) => + handleBatchStyleChangeComplete('fontItalic', value) + } + onUnderlineChange={(value) => + handleBatchStyleChangeComplete('fontUnderline', value) + } + onStrikethroughChange={(value) => + handleBatchStyleChangeComplete('fontStrikethrough', value) + } + /> + + + )} + )} {/* 커스텀 CSS 활성화 시에만 클래스명 및 CSS 우선순위 표시 */} {useCustomCSS && ( - <> - - + {/* CSS 우선순위 토글 */} -
-

+

+

{t('propertiesPanel.useInlineStyles') || '인라인 스타일 우선'}

= ({ isMixed={getMixedValue((pos) => pos.className, '').isMixed} /> - + )} {showSoundControls && @@ -859,18 +886,14 @@ const BatchStyleTabContent: React.FC = ({ const soundChangeComplete = handleKeyOnlyStyleChangeComplete ?? handleBatchStyleChangeComplete; return ( - <> - - + {soundMixedValue((pos) => pos.soundEnabled, false).isMixed ? ( - - Mixed - + Mixed ) : null} = ({ {soundMixedValue((pos) => pos.soundPath, '').isMixed ? ( - - Mixed - + Mixed ) : null} @@ -908,9 +932,7 @@ const BatchStyleTabContent: React.FC = ({ label={t('propertiesPanel.soundVolume') || '사운드 볼륨'} > {soundMixedValue((pos) => pos.soundVolume, 100).isMixed ? ( - - Mixed - + Mixed ) : null} pos.soundVolume, 100).value} @@ -928,50 +950,63 @@ const BatchStyleTabContent: React.FC = ({ } /> - + ); })()} - {/* FontPicker */} - {!hideFontControls && showFontPicker && ( - pos.fontFamily, null).value} - onFontSelect={(fontName) => { - handleBatchStyleChangeComplete('fontFamily', fontName); - }} - onClose={() => setShowFontPicker(false)} - interactiveRefs={[fontButtonRef]} - /> - )} - - {/* SoundPicker */} - {showSoundControls && showSoundPicker && ( - pos.soundPath, '') - .value || null - } - onSoundSelect={(soundPath) => { - ( - handleKeyOnlyStyleChangeComplete ?? handleBatchStyleChangeComplete - )('soundPath', soundPath || ''); - }} - onClose={() => setShowSoundPicker(false)} - interactiveRefs={[soundButtonRef]} - previewVolume={ - (getKeyOnlyMixedValue ?? getMixedValue)( - (pos) => pos.soundVolume, - 100, - ).value - } - /> - )} + {/* FontPicker — 패널 서브 페이지 */} + {!hideFontControls && + renderPageKey === FONT_PAGE_KEY && + pageHost && + createPortal( + pos.fontFamily, null).value} + onFontSelect={(fontName) => { + handleBatchStyleChangeComplete('fontFamily', fontName); + }} + onClose={closePage} + renderMode="page" + pageTitle={t('propertiesPanel.font') || '폰트'} + onBack={closePage} + />, + pageHost, + )} + + {/* SoundPicker — 패널 서브 페이지 */} + {showSoundControls && + renderPageKey === SOUND_PAGE_KEY && + pageHost && + createPortal( + pos.soundPath, + '', + ).value || null + } + onSoundSelect={(soundPath) => { + ( + handleKeyOnlyStyleChangeComplete ?? + handleBatchStyleChangeComplete + )('soundPath', soundPath || ''); + }} + onClose={closePage} + previewVolume={ + (getKeyOnlyMixedValue ?? getMixedValue)( + (pos) => pos.soundVolume, + 100, + ).value + } + renderMode="page" + pageTitle={t('propertiesPanel.keySound') || '키 사운드'} + onBack={closePage} + />, + pageHost, + )} ); }; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/index.ts b/src/renderer/components/main/Grid/PropertiesPanel/index.ts index bc37b988..9b16055e 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/index.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/index.ts @@ -4,6 +4,7 @@ export * from './types'; // UI 컴포넌트 export { PropertyRow, + PropertySection, NumberInput, OptionalNumberInput, TextInput, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerPanel.tsx index 50527b6c..f539dc74 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerPanel.tsx @@ -3,6 +3,7 @@ import { useTranslation } from '@contexts/useTranslation'; import { usePropertiesPanelStore } from '@stores/grid/usePropertiesPanelStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { SidebarToggleIcon, ModeToggleIcon } from '../PropertyInputs'; +import { PANEL_ROOT_CLASS } from '../panelChrome'; import { LAYER_PANEL_TABS, type LayerPanelTabType } from '../types'; import LayerTabContent from './LayerTabContent'; import GridTabContent from '../GridTabContent'; @@ -35,10 +36,8 @@ const LayerPanelTabButton: React.FC = ({ }) => ( - - - {/* 폰트 크기 */} - - handleCounterUpdate({ fontSize: value })} - suffix="px" - min={8} - max={72} - width="54px" - /> - - - {/* 폰트 스타일 */} - - = 700} - isItalic={counterSettings.fontItalic ?? false} - isUnderline={counterSettings.fontUnderline ?? false} - isStrikethrough={counterSettings.fontStrikethrough ?? false} - onBoldChange={(value) => - handleCounterUpdate({ fontWeight: value ? 700 : 400 }) - } - onItalicChange={(value) => handleCounterUpdate({ fontItalic: value })} - onUnderlineChange={(value) => - handleCounterUpdate({ fontUnderline: value }) - } - onStrikethroughChange={(value) => - handleCounterUpdate({ fontStrikethrough: value }) - } - /> - - - - - {/* 카운터 애니메이션 */} -
-

- {t('counterSetting.animationEnabled') || '카운터 애니메이션'} -

- - handleCounterUpdate({ - animation: { - ...counterSettings.animation, - enabled: !counterSettings.animation.enabled, - }, - }) - } - /> -
- - - - - - {pickerFor && pickerFor !== 'font' && ( + {/* 정렬 방식 (내부 배치 전용) */} + {counterSettings.placement === 'inside' && ( + + + handleCounterUpdate({ + alignMode: value as 'center' | 'between', + }) + } + /> + + )} + + {/* 간격 */} + + handleCounterUpdate({ gap: value })} + suffix="px" + min={0} + max={9999} + width="54px" + /> + + + + + {/* 채우기 색상 */} + + + + + {/* 폰트 크기 */} + + handleCounterUpdate({ fontSize: value })} + suffix="px" + min={8} + max={72} + width="54px" + /> + + + {/* 폰트 스타일 */} + + = 700} + isItalic={counterSettings.fontItalic ?? false} + isUnderline={counterSettings.fontUnderline ?? false} + isStrikethrough={counterSettings.fontStrikethrough ?? false} + onBoldChange={(value) => + handleCounterUpdate({ fontWeight: value ? 700 : 400 }) + } + onItalicChange={(value) => + handleCounterUpdate({ fontItalic: value }) + } + onUnderlineChange={(value) => + handleCounterUpdate({ fontUnderline: value }) + } + onStrikethroughChange={(value) => + handleCounterUpdate({ fontStrikethrough: value }) + } + /> + + + + + {/* 카운터 애니메이션 */} +
+

+ {t('counterSetting.animationEnabled') || '카운터 애니메이션'} +

+ + handleCounterUpdate({ + animation: { + ...counterSettings.animation, + enabled: !counterSettings.animation.enabled, + }, + }) + } + /> +
+ + + + +
+ + {pickerFor && ( = ({ /> )} - {/* FontPicker */} - {pickerFor === 'font' && ( - { - handleCounterUpdate({ fontFamily: fontName }); - }} - onClose={() => setPickerFor(null)} - interactiveRefs={[fontBtnRef]} - /> - )} - - {showAnimationPicker && ( - setShowAnimationPicker(false)} - t={t} - interactiveRefs={[animationBtnRef]} - /> - )} + {/* FontPicker — 패널 서브 페이지 */} + {renderPageKey === FONT_PAGE_KEY && + pageHost && + createPortal( + { + handleCounterUpdate({ fontFamily: fontName }); + }} + onClose={closePage} + renderMode="page" + pageTitle={t('counterSetting.font') || '폰트'} + onBack={closePage} + />, + pageHost, + )} + + {/* CounterAnimationPicker — 패널 서브 페이지 */} + {renderPageKey === ANIMATION_PAGE_KEY && + pageHost && + createPortal( + , + pageHost, + )} ); }; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx index c52d9646..a849f8af 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx @@ -4,9 +4,9 @@ import type { NoteTabContentProps } from '../types'; import type { NoteColor, KeyPosition } from '@src/types/key/keys'; import { PropertyRow, + PropertySection, NumberInput, OptionalNumberInput, - SectionDivider, } from '../PropertyInputs'; import Checkbox from '@components/main/common/Checkbox'; import Dropdown from '@components/main/common/Dropdown'; @@ -431,300 +431,305 @@ const NoteTabContent: React.FC = ({ return ( <> - {/* 노트 효과 표시 */} -
-

- {t('keySetting.noteEffectEnabled') || '노트 효과 표시'} -

- - handleStyleChangeComplete( - 'noteEffectEnabled', - !(keyPosition.noteEffectEnabled ?? true), - ) - } - /> -
- - {/* Y축 자동 보정 */} -
-

- {t('keySetting.noteAutoYCorrection') || 'Y축 자동 보정'} -

- - handleStyleChangeComplete( - 'noteAutoYCorrection', - !(keyPosition.noteAutoYCorrection ?? true), - ) - } - /> -
- - - - {/* 오프셋 */} - - handleStyleChangeComplete('noteOffsetX', value)} - prefix="X" - allowNegative - allowDecimal - decimalScale={1} - min={NOTE_SETTINGS_CONSTRAINTS.noteOffsetX.min} - max={NOTE_SETTINGS_CONSTRAINTS.noteOffsetX.max} - placeholder="0" - /> - handleStyleChangeComplete('noteOffsetY', value)} - prefix="Y" - allowNegative - allowDecimal - decimalScale={1} - min={NOTE_SETTINGS_CONSTRAINTS.noteOffsetY.min} - max={NOTE_SETTINGS_CONSTRAINTS.noteOffsetY.max} - placeholder="0" - /> - - - {/* 노트 넓이 */} - - handleStyleChangeComplete('noteWidth', value)} - suffix="px" - min={1} - allowDecimal - decimalScale={1} - placeholder={`${keyPosition.width}px`} - /> - - - {/* 노트 정렬 */} - - - handleStyleChangeComplete( - 'noteAlignment', - value as 'left' | 'center' | 'right', - ) - } - /> - - - + + {/* 노트 효과 표시 */} +
+

+ {t('keySetting.noteEffectEnabled') || '노트 효과 표시'} +

+ + handleStyleChangeComplete( + 'noteEffectEnabled', + !(keyPosition.noteEffectEnabled ?? true), + ) + } + /> +
- {/* 노트 색상 */} - -
+ + + {/* 노트 테두리 두께 */} + + + handleStyleChangeComplete('noteBorderWidth', value) + } + suffix="px" + min={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.min} + max={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.max} + allowDecimal + decimalScale={1} + /> + + + {/* 노트 라운딩 */} + + + handleStyleChangeComplete('noteBorderRadius', value) + } + suffix="px" + min={NOTE_SETTINGS_CONSTRAINTS.borderRadius.min} + max={NOTE_SETTINGS_CONSTRAINTS.borderRadius.max} + allowDecimal + decimalScale={1} + /> + + + + + {/* 글로우 효과 */} +
+

+ {t('keySetting.noteGlow') || '글로우 효과'} +

+ + handleStyleChangeComplete( + 'noteGlowEnabled', + !(keyPosition.noteGlowEnabled ?? false), ) } />
- - - {/* 노트 테두리 두께 */} - - - handleStyleChangeComplete('noteBorderWidth', value) - } - suffix="px" - min={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.min} - max={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.max} - allowDecimal - decimalScale={1} - /> - - - {/* 노트 라운딩 */} - - - handleStyleChangeComplete('noteBorderRadius', value) - } - suffix="px" - min={NOTE_SETTINGS_CONSTRAINTS.borderRadius.min} - max={NOTE_SETTINGS_CONSTRAINTS.borderRadius.max} - allowDecimal - decimalScale={1} - /> - - - - - {/* 글로우 효과 */} -
-

- {t('keySetting.noteGlow') || '글로우 효과'} -

- - handleStyleChangeComplete( - 'noteGlowEnabled', - !(keyPosition.noteGlowEnabled ?? false), - ) - } - /> -
- - {/* 글로우 색상/크기/투명도 */} - - - + + +
{useCustomCSS && ( - <> - - -
-

+ +

+

{t('propertiesPanel.useInlineStyles') || '인라인 스타일 우선'}

@@ -653,7 +656,7 @@ export const SingleGraphPanel: React.FC = ({ width="90px" /> - + )}
@@ -963,16 +966,13 @@ export const SingleKnobPanel: React.FC = ({ ); return ( -
-
+
+
{isRenaming ? ( setRenameValue(e.target.value)} onBlur={() => { @@ -994,7 +994,7 @@ export const SingleKnobPanel: React.FC = ({ ) : (
@@ -1002,7 +1002,7 @@ export const SingleKnobPanel: React.FC = ({ - - - - - - - handleKnobUpdate({ index: singleKnobIndex, dx: value }) - } - prefix="X" - min={-9999} - max={9999} - /> - - handleKnobUpdate({ index: singleKnobIndex, dy: value }) - } - prefix="Y" - min={-9999} - max={9999} - /> - - - - - handleKnobUpdate({ - index: singleKnobIndex, - width: Math.max(20, value), - }) - } - prefix="W" - min={20} - max={9999} - /> - - handleKnobUpdate({ - index: singleKnobIndex, - height: Math.max(20, value), - }) - } - prefix="H" - min={20} - max={9999} - /> - - - - - {/* 회전 배율: 1 = 물리 1회전당 화면 1회전 (축 해상도 무관) */} - - - handleKnobUpdate({ - index: singleKnobIndex, - sensitivity: Math.max(0, value), - }) - } - suffix="×" - min={0} - max={100} - allowDecimal - decimalScale={2} - /> - - -
-

- {t('propertiesPanel.knobReverse') || '방향 반전'} -

- - handleKnobUpdate({ - index: singleKnobIndex, - reverse: !(singleKnobPosition.reverse ?? false), - }) - } - /> -
+ + + + + + + + + + handleKnobUpdate({ index: singleKnobIndex, dx: value }) + } + prefix="X" + min={-9999} + max={9999} + /> + + handleKnobUpdate({ index: singleKnobIndex, dy: value }) + } + prefix="Y" + min={-9999} + max={9999} + /> + - + + + handleKnobUpdate({ + index: singleKnobIndex, + width: Math.max(20, value), + }) + } + prefix="W" + min={20} + max={9999} + /> + + handleKnobUpdate({ + index: singleKnobIndex, + height: Math.max(20, value), + }) + } + prefix="H" + min={20} + max={9999} + /> + + - {/* 배경색 (대기/입력 상태 전환은 피커 내부 토글) */} - - - + + handleKnobUpdate({ + index: singleKnobIndex, + borderWidth: value, + }) + } + suffix="px" + min={0} + max={20} + /> + - {useCustomCSS && ( - - + {/* 모서리 반경 (미지정 시 원형) */} + + handleKnobUpdate({ index: singleKnobIndex, - className: classNameDraft || '', + borderRadius: value, }) } - placeholder="className" - width="90px" + suffix="px" + min={0} + max={999} /> - )} + + + + + + {useCustomCSS && ( + + + handleKnobUpdate({ + index: singleKnobIndex, + className: classNameDraft || '', + }) + } + placeholder="className" + width="90px" + /> + + )} +
= ({ ) : undefined; return ( -
+
{/* 헤더 + 탭 영역 */} -
+
{/* 헤더 */} -
+
{isRenaming ? ( setRenameValue(e.target.value)} onBlur={() => { @@ -1562,7 +1561,7 @@ export const SingleKeyStatPanel: React.FC = ({ ) : (
@@ -1570,7 +1569,7 @@ export const SingleKeyStatPanel: React.FC = ({ - - + ) : null} - {/* 위치 */} - - - - - - {/* 크기 */} - - - - + {/* 위치·크기 */} + + + + + - + {/* 크기 */} + + + + + - {/* 배경색 */} - - + + )} + + + {/* 텍스트·폰트 */} + + {/* 표시 텍스트 */} + {!hideDisplayText && ( + + + + )} + + {/* 폰트 */} + + - )} - - - {/* 표시 텍스트 */} - {!hideDisplayText && ( - - + handleStyleChangeComplete('fontSize', value)} + suffix="px" + min={8} + max={72} + allowDecimal + decimalScale={1} /> - )} - {/* 폰트 */} - - - - - {/* 글꼴 크기 */} - - handleStyleChangeComplete('fontSize', value)} - suffix="px" - min={8} - max={72} - allowDecimal - decimalScale={1} - /> - - - {/* 글꼴 색상 */} - - {/* 서브메뉴 */} @@ -326,6 +342,11 @@ const MenuItemRow = ({ textAlign={textAlign} maxVisibleItems={item.maxVisibleChildren} anchorRect={rowRect} + onMouseEnter={() => { + // 포털로 DOM 중첩이 끊기므로 서브메뉴 진입 시 닫힘 타이머 취소 + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); + }} + onMouseLeave={handleMouseLeave} /> )}
@@ -345,19 +366,24 @@ const ListPopup = ({ textAlign = 'center', maxVisibleItems, }: ListPopupProps) => { + // 일시적 팝업은 상주 크롬(z-30, 패널·미니맵)보다 항상 위 const defaultClassName = - 'z-30 bg-button-primary rounded-[7px] p-[5px] flex flex-col gap-[1px]'; + 'z-40 bg-glass backdrop-blur-[24px] shadow-elevation-2 rounded-[10px] p-[4px] flex flex-col gap-[4px]'; const effectiveClassName = `${defaultClassName} ${className}`.trim(); - // 스크롤 필요 여부 계산 - const itemHeight = 24; + // 스크롤 필요 여부 계산 (아이템 26 + 갭 4 리듬) + const itemHeight = 26; + const itemGap = 4; const separatorCount = items.filter((i) => i.type === 'separator').length; const normalItemCount = items.length - separatorCount; const needsScroll = maxVisibleItems != null && normalItemCount > maxVisibleItems; const maxHeight = needsScroll - ? maxVisibleItems * itemHeight + separatorCount * 9 + 10 + ? maxVisibleItems * (itemHeight + itemGap) + + separatorCount * (1 + itemGap) + + 6 : undefined; + const hasCheckColumn = items.some((it) => typeof it.checked === 'boolean'); const siblingActiveRef = useRef<{ id: string | null; @@ -365,7 +391,6 @@ const ListPopup = ({ }>({ id: null, close: null }); const { scrollContainerRef: lenisRef } = useLenis({ - duration: 0.5, wheelMultiplier: 0.7, }); @@ -389,7 +414,7 @@ const ListPopup = ({ ? { maxHeight, overflowY: 'auto', overflowX: 'hidden' } : undefined } - className={`flex flex-col gap-[1px]${ + className={`flex flex-col gap-[4px]${ needsScroll ? ' listpopup-scroll' : '' }`} > @@ -401,6 +426,7 @@ const ListPopup = ({ onSelect={onSelect} onCloseAll={onClose} siblingActiveRef={siblingActiveRef} + hasCheckColumn={hasCheckColumn} /> ))}
diff --git a/src/renderer/components/main/Modal/ManagerModalLayout.tsx b/src/renderer/components/main/Modal/ManagerModalLayout.tsx index 2e478e40..c3492aa7 100644 --- a/src/renderer/components/main/Modal/ManagerModalLayout.tsx +++ b/src/renderer/components/main/Modal/ManagerModalLayout.tsx @@ -113,7 +113,7 @@ const ManagerModalLayout = ({ return (
e.stopPropagation()} > {/* 탭 영역 */} @@ -123,7 +123,7 @@ const ManagerModalLayout = ({
{/* 상단 그림자 */}
@@ -149,17 +149,17 @@ const ManagerModalLayout = ({ {/* 하단 그림자 */}
{/* 구분선 */} -
+
{/* 하단 버튼 */} -
{footer}
+
{footer}
{/* 추가 콘텐츠 (로딩/에러 등) */} {extra} diff --git a/src/renderer/components/main/Modal/Modal.tsx b/src/renderer/components/main/Modal/Modal.tsx index 63b3e785..9d9784b7 100644 --- a/src/renderer/components/main/Modal/Modal.tsx +++ b/src/renderer/components/main/Modal/Modal.tsx @@ -47,7 +47,7 @@ const Modal = ({ onClick, children, animate = true }: ModalProps) => { return createPortal(
e.stopPropagation()} > {/* 메시지 텍스트 or Custom HTML */} @@ -88,14 +88,14 @@ const Alert = ({
{/* 상단 그림자 */}
0 @@ -115,13 +115,13 @@ const Alert = ({ {/* 하단 그림자 */}
) : ( -
+
{message}
)} @@ -130,20 +130,20 @@ const Alert = ({
{shouldShowCancel && ( diff --git a/src/renderer/components/main/Modal/content/dialogs/Laboratory.tsx b/src/renderer/components/main/Modal/content/dialogs/Laboratory.tsx index a99f9a5c..313f6cb7 100644 --- a/src/renderer/components/main/Modal/content/dialogs/Laboratory.tsx +++ b/src/renderer/components/main/Modal/content/dialogs/Laboratory.tsx @@ -83,11 +83,11 @@ const LaboratoryModal = ({ return (
e.stopPropagation()} >
-

+

{t('laboratory.delayToggle')}

-

{t('laboratory.minLength')}

+

+ {t('laboratory.minLength')} +

-

{t('laboratory.threshold')}

+

+ {t('laboratory.threshold')} +

{/* 구분선 */} -
+
{/* 키 딜레이 설정 */}
-

{t('laboratory.keyDelay')}

+

{t('laboratory.keyDelay')}

-

+

{t('laboratory.keyDelayAuto', { value: calculatedDelay })}

-
+
diff --git a/src/renderer/components/main/Modal/content/dialogs/PluginDataDeleteModal.tsx b/src/renderer/components/main/Modal/content/dialogs/PluginDataDeleteModal.tsx index 96eebeb8..559eacdb 100644 --- a/src/renderer/components/main/Modal/content/dialogs/PluginDataDeleteModal.tsx +++ b/src/renderer/components/main/Modal/content/dialogs/PluginDataDeleteModal.tsx @@ -23,22 +23,22 @@ export function PluginDataDeleteModal({ return (
event.stopPropagation()} >
- {/* + {/* {t("settings.pluginDataDeleteTitle")} */} - + {t('settings.pluginDataDeleteMessage', { name: pluginName })}
-
+
- ⚠️ - + ⚠️ + {t('settings.pluginDataDeleteWarning')}
@@ -46,19 +46,19 @@ export function PluginDataDeleteModal({
diff --git a/src/renderer/components/main/Modal/content/dialogs/UpdateModal.tsx b/src/renderer/components/main/Modal/content/dialogs/UpdateModal.tsx index 5dc0a5fc..59373eec 100644 --- a/src/renderer/components/main/Modal/content/dialogs/UpdateModal.tsx +++ b/src/renderer/components/main/Modal/content/dialogs/UpdateModal.tsx @@ -81,7 +81,7 @@ const UpdateModal = ({ return (
e.stopPropagation()} > {isLatestVersion ? ( @@ -89,13 +89,13 @@ const UpdateModal = ({ <> {/* 헤더 */}
-
+
-

+

{t('update.latestAlready')}

{/* 버전 정보 */} -
+
- + {t('update.currentVersion')} - + {updateInfo.currentVersion}
@@ -126,15 +126,15 @@ const UpdateModal = ({
@@ -145,7 +145,7 @@ const UpdateModal = ({ <> {/* 헤더 */}
-
+
@@ -167,30 +167,30 @@ const UpdateModal = ({
-

+

{t('update.title')}

-

+

{formatDate(updateInfo.publishedAt)}

{/* 버전 정보 */} -
+
- + {t('update.currentVersion')} - + {updateInfo.currentVersion}
- + {t('update.latestVersion')} - + {updateInfo.latestVersion}
@@ -202,15 +202,15 @@ const UpdateModal = ({ type="checkbox" checked={skipChecked} onChange={(e) => setSkipChecked(e.target.checked)} - className="w-[14px] h-[14px] rounded-[3px] border border-[#3A3A40] bg-[#222127] - checked:bg-[#4A4A52] checked:border-[#5A5A62] cursor-pointer + className="w-[14px] h-[14px] rounded-[3px] border border-line-strong bg-inset + checked:bg-accent checked:border-accent cursor-pointer appearance-none relative after:content-[''] after:absolute after:hidden after:left-[4px] after:top-[1px] after:w-[4px] after:h-[8px] after:border-r-[2px] after:border-b-[2px] after:border-white after:rotate-45 checked:after:block" /> - + {t('update.skipVersion')} @@ -220,16 +220,16 @@ const UpdateModal = ({ diff --git a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx index f82ff35e..1090b3f6 100644 --- a/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx +++ b/src/renderer/components/main/Modal/content/editors/CounterAnimationEditorModal.tsx @@ -843,21 +843,21 @@ const CounterAnimationEditorModal = ({ return (
event.stopPropagation()} > -
+
- + Motion - + {headerTitle}
-
+
-
+
-