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 = ({ @@ -2561,298 +2629,385 @@ const PropertiesPanel: React.FC = ({ ); } - if (pluginSettingsPanel) { - return ( - - ); - } + // 선택 상태별 구상 패널 — 프레임(글래스) 안의 루트 페이지 콘텐츠 + const renderPanelBody = () => { + if (pluginSettingsPanel) { + return ( + + ); + } - // 레이어 모드일 때는 선택 여부와 관계없이 레이어 패널 표시 - if (panelMode === 'layer') { - const hasAnySelection = - selectedKeyElements.length > 0 || selectedElements.length > 0; - return ( - { - selectionFromLayerPanelRef.current = true; - }} - /> - ); - } + // 레이어 모드일 때는 선택 여부와 관계없이 레이어 패널 표시 + if (panelMode === 'layer') { + const hasAnySelection = + selectedKeyElements.length > 0 || selectedElements.length > 0; + return ( + { + selectionFromLayerPanelRef.current = true; + }} + /> + ); + } - // 선택된 키 요소가 없으면 레이어 패널 표시 - if (selectedKeyElements.length === 0 && selectedElements.length === 0) { - return ( - { - selectionFromLayerPanelRef.current = true; - }} - /> - ); - } + // 선택된 키 요소가 없으면 레이어 패널 표시 + if (selectedKeyElements.length === 0 && selectedElements.length === 0) { + return ( + { + selectionFromLayerPanelRef.current = true; + }} + /> + ); + } - // 다중 선택인 경우 (키/통계 포함, 또는 그래프+노브 혼합) - if ( - selectedBatchStyleElements.length > 1 && - selectedPluginElements.length === 0 && - (selectedKeyLikeElements.length > 0 || - (selectedGraphElements.length > 0 && selectedKnobElements.length > 0)) - ) { - return ( - - ); - } + // 다중 선택인 경우 (키/통계 포함, 또는 그래프+노브 혼합) + if ( + selectedBatchStyleElements.length > 1 && + selectedPluginElements.length === 0 && + (selectedKeyLikeElements.length > 0 || + (selectedGraphElements.length > 0 && selectedKnobElements.length > 0)) + ) { + return ( + + ); + } - // 다중 선택인 경우 (노브 요소만) - if ( - selectedKnobElements.length > 1 && - selectedKeyLikeElements.length === 0 && - selectedGraphElements.length === 0 && - selectedPluginElements.length === 0 - ) { - return ( - - ); - } + // 다중 선택인 경우 (노브 요소만) + if ( + selectedKnobElements.length > 1 && + selectedKeyLikeElements.length === 0 && + selectedGraphElements.length === 0 && + selectedPluginElements.length === 0 + ) { + return ( + + ); + } - // 다중 선택인 경우 (그래프 요소만) - if ( - selectedGraphElements.length > 1 && - selectedKeyLikeElements.length === 0 && - selectedKnobElements.length === 0 && - selectedPluginElements.length === 0 - ) { - return ( - - ); - } + // 다중 선택인 경우 (그래프 요소만) + if ( + selectedGraphElements.length > 1 && + selectedKeyLikeElements.length === 0 && + selectedKnobElements.length === 0 && + selectedPluginElements.length === 0 + ) { + return ( + + ); + } - // 플러그인 요소가 선택된 경우 - if ( - selectedPluginElements.length > 0 && - selectedKeyLikeElements.length === 0 && - selectedGraphElements.length === 0 - ) { - const pluginTitle = - selectedPluginDefinition?.name || - selectedPluginElement?.definitionId || - t('propertiesPanel.pluginElement') || - 'Plugin'; + // 플러그인 요소가 선택된 경우 + if ( + selectedPluginElements.length > 0 && + selectedKeyLikeElements.length === 0 && + selectedGraphElements.length === 0 + ) { + const pluginTitle = + selectedPluginDefinition?.name || + selectedPluginElement?.definitionId || + t('propertiesPanel.pluginElement') || + 'Plugin'; - return ( - - ); - } + return ( + + ); + } + + // 단일 노브 요소 선택인 경우 + if ( + selectedKnobElements.length === 1 && + !!singleKnobPosition && + selectedKeyLikeElements.length === 0 && + selectedGraphElements.length === 0 && + selectedPluginElements.length === 0 + ) { + return ( + + ); + } + + // 단일 그래프 요소 선택인 경우 + if ( + selectedGraphElements.length === 1 && + !!singleGraphPosition && + selectedKeyLikeElements.length === 0 && + selectedPluginElements.length === 0 + ) { + return ( + + ); + } + + // 단일 키/통계 요소 선택인 경우 + const isSingleStat = !singleKeyPosition && !!singleStatPosition; + const isSingleKey = !!singleKeyPosition; + if (!isSingleKey && !isSingleStat) { + return null; + } - // 단일 노브 요소 선택인 경우 - if ( - selectedKnobElements.length === 1 && - !!singleKnobPosition && - selectedKeyLikeElements.length === 0 && - selectedGraphElements.length === 0 && - selectedPluginElements.length === 0 - ) { return ( - = ({ handleRenameCommit={handleRenameCommit} handleRenameCancel={handleRenameCancel} handleRenameStart={handleRenameStart} - handleKnobUpdate={handleKnobUpdate} handleToggleMode={handleToggleMode} handleTogglePanel={handleTogglePanel} - singleScrollRefFor={singleScrollRefFor} - singleThumbRefFor={singleThumbRefFor} + activeTab={activeTab} + setActiveTab={setActiveTab} + onPositionChange={onPositionChange} + onKeyUpdate={onKeyUpdate} + onKeyPreview={onKeyPreview} + onKeyMappingChange={onKeyMappingChange} + handleStatUpdate={handleStatUpdate} + handleStatPreview={handleStatPreview} + isListening={isListening} + handleKeyListen={handleKeyListen} + localState={localState} + setLocalState={setLocalState} + handleSizeBlur={handleSizeBlur} + showImagePicker={showImagePicker} + setShowImagePicker={setShowImagePicker} + imageButtonRef={imageButtonRef} panelElement={panelElement} useCustomCSS={useCustomCSS} - t={t} - /> - ); - } - - // 단일 그래프 요소 선택인 경우 - if ( - selectedGraphElements.length === 1 && - !!singleGraphPosition && - selectedKeyLikeElements.length === 0 && - selectedPluginElements.length === 0 - ) { - return ( - ); - } + }; - // 단일 키/통계 요소 선택인 경우 - const isSingleStat = !singleKeyPosition && !!singleStatPosition; - const isSingleKey = !!singleKeyPosition; - if (!isSingleKey && !isSingleStat) { - return null; - } + const panelBody = renderPanelBody(); + if (!panelBody) return null; + // 프레임이 글래스를 소유하고, 루트/서브 페이지가 그 안에서 슬라이드 전환 return ( - + + + + {panelBody} + + + + ); }; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/GridTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/GridTabContent.tsx index cb7466dd..46f3a07d 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/GridTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/GridTabContent.tsx @@ -1,21 +1,21 @@ import React from 'react'; import { useTranslation } from '@contexts/useTranslation'; import { useSettingsStore, type GridSettings } from '@stores/useSettingsStore'; -import { SectionDivider, PropertyRow, NumberInput } from './PropertyInputs'; +import { PropertySection, PropertyRow, NumberInput } from './PropertyInputs'; import Checkbox from '@components/main/common/Checkbox'; // ============================================================================ // 그리드 탭 콘텐츠 컴포넌트 // ============================================================================ -// 체크박스 행 컴포넌트 (높이 23px 고정) +// 체크박스 행 컴포넌트 const CheckboxRow: React.FC<{ label: string; checked: boolean; onChange: () => void; }> = ({ label, checked, onChange }) => ( - - {label} + + {label} ); @@ -42,66 +42,74 @@ const GridTabContent: React.FC = () => { }; return ( - - {/* 그리드 스냅 크기 */} - - handleSettingChange('gridSnapSize', val)} - min={1} - max={10} - suffix="px" - width="54px" - /> - - - {/* 오버레이 여백 */} - - handleSettingChange('overlayPadding', val)} - min={0} - max={30} - suffix="px" - width="54px" - /> - + + {/* 그리드 */} + + + handleSettingChange('gridSnapSize', val)} + min={1} + max={10} + suffix="px" + width="54px" + /> + - + {/* 오버레이 여백 */} + + handleSettingChange('overlayPadding', val)} + min={0} + max={30} + suffix="px" + width="54px" + /> + + {/* 미니맵 */} - - handleSettingChange('minimapEnabled', !gridSettings.minimapEnabled) - } - /> - - + + + handleSettingChange('minimapEnabled', !gridSettings.minimapEnabled) + } + /> + {/* 스마트 가이드 */} - - handleSettingChange('alignmentGuides', !gridSettings.alignmentGuides) - } - /> - - handleSettingChange('spacingGuides', !gridSettings.spacingGuides) - } - /> - - handleSettingChange('sizeMatchGuides', !gridSettings.sizeMatchGuides) - } - /> + + + handleSettingChange( + 'alignmentGuides', + !gridSettings.alignmentGuides, + ) + } + /> + + handleSettingChange('spacingGuides', !gridSettings.spacingGuides) + } + /> + + handleSettingChange( + 'sizeMatchGuides', + !gridSettings.sizeMatchGuides, + ) + } + /> + ); }; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PanelNavContext.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PanelNavContext.tsx new file mode 100644 index 00000000..54ca33fb --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/PanelNavContext.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext } from 'react'; + +// 사이드 패널 인-패널 내비게이션 — 탭 콘텐츠가 자기 클로저에서 피커를 렌더하되 +// createPortal로 패널 루트의 서브 페이지 호스트에 그려 넣는다 +export interface PanelNavState { + // 애니메이션 상태 기준 — 열림/닫힘 즉시 반영 + activePageKey: string | null; + // 마운트 기준 — exit 전환이 끝날 때까지 유지 (빈 페이지 슬라이드 방지) + renderPageKey: string | null; + openPage: (key: string) => void; + closePage: () => void; + // 서브 페이지 포털 타깃 (state로 보관 — 렌더 중 ref 읽기 회피) + pageHost: HTMLDivElement | null; +} + +const PanelNavContext = createContext(null); + +export const PanelNavProvider = PanelNavContext.Provider; + +export const usePanelNav = (): PanelNavState => { + const nav = useContext(PanelNavContext); + if (!nav) { + throw new Error('usePanelNav는 PanelNavProvider 내부에서만 사용 가능'); + } + return nav; +}; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PluginSettingsPanelView.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PluginSettingsPanelView.tsx index d642bc9b..ab3a8e19 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/PluginSettingsPanelView.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/PluginSettingsPanelView.tsx @@ -6,6 +6,7 @@ import type { } from '@src/types/plugin/api'; import type { PluginSettingsPanelPayload } from '@stores/grid/usePropertiesPanelStore'; import { SidebarToggleIcon } from './PropertyInputs'; +import { PANEL_ROOT_CLASS } from './panelChrome'; interface PluginSettingsPanelViewProps { setPanelElement: (el: HTMLDivElement | null) => void; @@ -43,22 +44,19 @@ const PluginSettingsPanelView: React.FC = ({ t, }) => { return ( - - + + - + {t('propertiesPanel.pluginSettings') || '플러그인 설정'} - + {pluginSettingsPanel.pluginId} @@ -69,7 +67,7 @@ const PluginSettingsPanelView: React.FC = ({ ref={setPluginScrollRef} className="properties-panel-overlay-viewport" > - + {renderPluginSettingsForm( pluginSettingsPanel.definition.settings, pluginPanelSettings, @@ -87,17 +85,17 @@ const PluginSettingsPanelView: React.FC = ({ - + {t('common.cancel') || '취소'} {t('common.save') || '저장'} 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 = ({ = ({ onChange, }) => { const [isOpen, setIsOpen] = useState(false); + const [menuPos, setMenuPos] = useState<{ + left: number; + top: number; + width: number; + } | null>(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + + // 트리거가 스크롤/리사이즈로 움직이면 좌표가 어긋나므로 닫는다 + // (메뉴 내부 스크롤은 제외) + useEffect(() => { + if (!isOpen) return; + const close = (event?: Event) => { + if ( + event?.target instanceof Node && + menuRef.current?.contains(event.target) + ) { + return; + } + setIsOpen(false); + }; + window.addEventListener('scroll', close, true); + window.addEventListener('resize', close); + return () => { + window.removeEventListener('scroll', close, true); + window.removeEventListener('resize', close); + }; + }, [isOpen]); return ( setIsOpen(!isOpen)} - className={`h-[23px] min-w-[70px] bg-[#2A2A30] rounded-[7px] border-[1px] ${ - isOpen ? 'border-[#459BF8]' : 'border-[#3A3943]' - } px-[8px] flex items-center justify-between gap-[4px] hover:border-[#505058] transition-colors`} + ref={triggerRef} + onClick={() => { + if (!isOpen && triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + setMenuPos({ + left: rect.left, + top: rect.bottom + 4, + width: rect.width, + }); + } + setIsOpen(!isOpen); + }} + className={`h-[23px] min-w-[70px] bg-inset rounded-md ${ + isOpen ? 'shadow-focus-ring' : '' + } px-[8px] flex items-center justify-between gap-[4px] transition-colors`} > - + {options.find((opt) => opt.value === value)?.label || value} = ({ height="5" viewBox="0 0 8 5" fill="none" - className="flex-shrink-0" + className="flex-shrink-0 text-white/45" > - {isOpen && ( - <> - setIsOpen(false)} - /> - - {options.map((opt) => ( - { - onChange(opt.value); - setIsOpen(false); - }} - className={`w-full px-[8px] py-[6px] text-left text-style-4 hover:bg-[#32323A] transition-colors ${ - value === opt.value ? 'text-[#459BF8]' : 'text-[#DBDEE8]' - }`} - > - {opt.label} - - ))} - - > - )} + {isOpen && + menuPos && + createPortal( + // 패널의 backdrop-filter·mask 아래에서는 중첩 backdrop-blur가 + // 무력화되므로 메뉴는 body로 포털해 backdrop root 밖에서 그린다 + <> + setIsOpen(false)} + /> + + {options.map((opt) => ( + { + onChange(opt.value); + setIsOpen(false); + }} + className={`text-left w-full h-[24px] px-[8px] rounded-[6px] text-body tabular-nums transition-colors duration-fast flex items-center ${ + value === opt.value + ? 'bg-surface-active text-fg pointer-events-none' + : 'text-fg-muted hover:bg-surface-hover hover:text-fg' + }`} + > + {opt.label} + + ))} + + >, + document.body, + )} ); }; @@ -830,7 +893,7 @@ export const ToggleSwitch: React.FC = ({ onChange(!checked)} className={`w-[32px] h-[18px] rounded-full transition-colors relative flex-shrink-0 ${ - checked ? 'bg-[#459BF8]' : 'bg-[#3A3943]' + checked ? 'bg-accent' : 'bg-line-strong' }`} > = ({ // ============================================================================ export const SectionDivider: React.FC = () => ( - + ); // ============================================================================ @@ -964,14 +1027,14 @@ export const FontStyleToggle: React.FC = ({ onStrikethroughChange, }) => { const buttonClass = (active: boolean) => - `w-[24px] h-[21px] flex items-center justify-center transition-colors ${ + `w-[24px] h-[21px] flex items-center justify-center transition-colors duration-fast ${ active - ? 'bg-[#493C1D] text-[#FFB400]' - : 'bg-[#2A2A30] text-[#6B6D75] hover:bg-[#32323A] hover:text-[#97999E]' + ? 'bg-fill-active text-fg' + : 'bg-inset text-fg-faint hover:bg-surface-hover hover:text-fg-muted' }`; return ( - + onBoldChange(!isBold)} className={buttonClass(isBold)} @@ -1011,10 +1074,8 @@ export const FontStyleToggle: React.FC = ({ const TabButton: React.FC = ({ active, onClick, children }) => ( {children} @@ -1031,32 +1092,33 @@ export const Tabs: React.FC = ({ ? availableTabs : [TABS.STYLE, TABS.NOTE, TABS.COUNTER]; + const labels: Record = { + [TABS.STYLE]: t('propertiesPanel.tabStyle') || '키', + [TABS.NOTE]: t('propertiesPanel.tabNote') || '노트', + [TABS.COUNTER]: t('propertiesPanel.tabCounter') || '카운터', + }; + + const activeIndex = Math.max(0, tabs.indexOf(activeTab)); + return ( - - {tabs.includes(TABS.STYLE) && ( - onTabChange(TABS.STYLE)} - > - {t('propertiesPanel.tabStyle') || '키'} - - )} - {tabs.includes(TABS.NOTE) && ( - onTabChange(TABS.NOTE)} - > - {t('propertiesPanel.tabNote') || '노트'} - - )} - {tabs.includes(TABS.COUNTER) && ( + + + {tabs.map((tab) => ( onTabChange(TABS.COUNTER)} + key={tab} + active={activeTab === tab} + onClick={() => onTabChange(tab)} > - {t('propertiesPanel.tabCounter') || '카운터'} + {labels[tab]} - )} + ))} ); }; @@ -1069,16 +1131,17 @@ export const CloseIcon: React.FC = () => ( ); -export const SidebarToggleIcon: React.FC<{ isOpen: boolean }> = ({ - isOpen, -}) => ( +export const SidebarToggleIcon: React.FC<{ + isOpen: boolean; + color?: string; +}> = ({ isOpen, color = 'currentColor' }) => ( = ({ width="14.5" height="12.5" rx="2" - stroke="#6B6D75" + stroke={color} strokeWidth="1.5" fill="none" /> @@ -1095,7 +1158,7 @@ export const SidebarToggleIcon: React.FC<{ isOpen: boolean }> = ({ y1="1" x2={isOpen ? '10' : '12'} y2="13" - stroke="#6B6D75" + stroke={color} strokeWidth="1.5" /> {isOpen && ( @@ -1105,7 +1168,7 @@ export const SidebarToggleIcon: React.FC<{ isOpen: boolean }> = ({ y1="4" x2="13.5" y2="4" - stroke="#6B6D75" + stroke={color} strokeWidth="1" strokeLinecap="round" /> @@ -1114,7 +1177,7 @@ export const SidebarToggleIcon: React.FC<{ isOpen: boolean }> = ({ y1="7" x2="13.5" y2="7" - stroke="#6B6D75" + stroke={color} strokeWidth="1" strokeLinecap="round" /> @@ -1123,7 +1186,7 @@ export const SidebarToggleIcon: React.FC<{ isOpen: boolean }> = ({ y1="10" x2="13.5" y2="10" - stroke="#6B6D75" + stroke={color} strokeWidth="1" strokeLinecap="round" /> @@ -1137,13 +1200,20 @@ export const ModeToggleIcon: React.FC<{ mode: 'layer' | 'property'; disabled?: boolean; }> = ({ mode, disabled = false }) => { - const strokeColor = disabled ? '#4A4A50' : '#6B6D75'; - const fillColor = disabled ? '#4A4A50' : '#6B6D75'; + const strokeColor = 'currentColor'; + const fillColor = 'currentColor'; + const disabledClass = disabled ? 'text-fg-disabled' : undefined; if (mode === 'layer') { // 레이어 아이콘 (쌓인 레이어) return ( - + + string; } @@ -71,13 +76,13 @@ const BatchCounterTabContent: React.FC = ({ batchCounterStrokeButtonRef, isFillPickerOpen, isStrokePickerOpen, - panelElement, t, }) => { const fontButtonRef = useRef(null); const animationButtonRef = useRef(null); - const [showFontPicker, setShowFontPicker] = useState(false); - const [showAnimationPicker, setShowAnimationPicker] = useState(false); + // 인-패널 내비게이션 (폰트/애니메이션 서브 페이지) + const { activePageKey, renderPageKey, openPage, closePage, pageHost } = + usePanelNav(); const handleAnimationUpdate = ( nextAnimation: KeyCounterSettings['animation'], @@ -94,263 +99,278 @@ const BatchCounterTabContent: React.FC = ({ return ( <> - {/* 카운터 사용 */} - - - {t('counterSetting.counterEnabled') || '카운터 표시'} - - - handleBatchCounterUpdate({ - enabled: !batchCounterSettings.enabled, - }) - } - /> - - - - - {/* 배치 영역 */} - - - handleBatchCounterUpdate({ - placement: value as 'inside' | 'outside', - }) - } - /> - - - {/* 정렬 방향 */} - - - handleBatchCounterUpdate({ - align: value as 'top' | 'bottom' | 'left' | 'right', - }) - } - /> - + + {/* 카운터 사용 */} + + + {t('counterSetting.counterEnabled') || '카운터 표시'} + + + handleBatchCounterUpdate({ + enabled: !batchCounterSettings.enabled, + }) + } + /> + + - {/* 정렬 방식 (내부 배치 전용) */} - {batchCounterSettings.placement === 'inside' && ( - + + {/* 배치 영역 */} + handleBatchCounterUpdate({ - alignMode: value as 'center' | 'between', + placement: value as 'inside' | 'outside', }) } /> - )} - - {/* 간격 */} - - handleBatchCounterUpdate({ gap: value })} - suffix="px" - min={0} - max={9999} - width="54px" - /> - - + {/* 정렬 방향 */} + + + handleBatchCounterUpdate({ + align: value as 'top' | 'bottom' | 'left' | 'right', + }) + } + /> + - {/* 채우기 색상 */} - - - + {/* 정렬 방식 (내부 배치 전용) */} + {batchCounterSettings.placement === 'inside' && ( + + + handleBatchCounterUpdate({ + alignMode: value as 'center' | 'between', + }) + } + /> + + )} - {/* 외곽선 색상 */} - - - + {/* 간격 */} + + handleBatchCounterUpdate({ gap: value })} + suffix="px" + min={0} + max={9999} + width="54px" + /> + + - + + {/* 채우기 색상 */} + + + - {/* 폰트 */} - - setShowFontPicker((prev) => !prev)} - > - {t('propertiesPanel.configure') || '설정하기'} - - + {/* 외곽선 색상 */} + + + + - {/* 폰트 크기 */} - - handleBatchCounterUpdate({ fontSize: value })} - suffix="px" - min={8} - max={72} - width="54px" - /> - + + {/* 폰트 */} + + { + if (activePageKey === FONT_PAGE_KEY) closePage(); + else openPage(FONT_PAGE_KEY); + }} + > + {t('propertiesPanel.configure') || '설정하기'} + + - {/* 폰트 스타일 */} - - = 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, + }, + }) + } + /> + - - setShowAnimationPicker((prev) => !prev)} - > - {t('propertiesPanel.configure') || '설정하기'} - - + + { + if (activePageKey === ANIMATION_PAGE_KEY) closePage(); + else openPage(ANIMATION_PAGE_KEY); + }} + > + {t('propertiesPanel.configure') || '설정하기'} + + + - {/* 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.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 + )} - {getMixedValue((pos) => pos.noteBorderSide, 'all').value === - 'all' && ( - - )} - {getMixedValue((pos) => pos.noteBorderSide, 'all').value === - 'vertical' && ( - <> - - - > - )} - {getMixedValue((pos) => pos.noteBorderSide, 'all').value === - 'horizontal' && ( - <> - - - > - )} - - } - align="right" options={[ { - label: t('keySetting.borderSideAll') || '전체', - value: 'all', + label: t('keySetting.noteAlignLeft') || '좌측', + value: 'left', }, { - label: t('keySetting.borderSideVertical') || '수직', - value: 'vertical', + label: t('keySetting.noteAlignCenter') || '중앙', + value: 'center', }, { - label: t('keySetting.borderSideHorizontal') || '수평', - value: 'horizontal', + label: t('keySetting.noteAlignRight') || '우측', + value: 'right', }, ]} - value={getMixedValue((pos) => pos.noteBorderSide, 'all').value} + value={getMixedValue((pos) => pos.noteAlignment, 'center').value} onChange={(value) => handleBatchStyleChangeComplete( - 'noteBorderSide', - value as 'all' | 'vertical' | 'horizontal', + 'noteAlignment', + value as 'left' | 'center' | 'right', ) } /> - - + + - {/* 테두리 두께 */} - - 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.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 - } - /> - + {/* 테두리 색상 + 방향 */} + + + + + {getMixedValue((pos) => pos.noteBorderSide, 'all').value === + 'all' && ( + + )} + {getMixedValue((pos) => pos.noteBorderSide, 'all').value === + 'vertical' && ( + <> + + + > + )} + {getMixedValue((pos) => pos.noteBorderSide, 'all').value === + 'horizontal' && ( + <> + + + > + )} + + } + align="right" + options={[ + { + label: t('keySetting.borderSideAll') || '전체', + value: 'all', + }, + { + label: t('keySetting.borderSideVertical') || '수직', + value: 'vertical', + }, + { + label: t('keySetting.borderSideHorizontal') || '수평', + value: 'horizontal', + }, + ]} + value={getMixedValue((pos) => pos.noteBorderSide, 'all').value} + onChange={(value) => + handleBatchStyleChangeComplete( + 'noteBorderSide', + value as 'all' | 'vertical' | 'horizontal', + ) + } + /> + + - + {/* 테두리 두께 */} + + 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('keySetting.noteGlow') || '글로우 효과'} + + pos.noteGlowEnabled, false).value} + onChange={() => { + const currentValue = getMixedValue( + (pos) => pos.noteGlowEnabled, + false, + ).value; + handleBatchStyleChangeComplete('noteGlowEnabled', !currentValue); + }} + /> + - {/* 글로우 크기 */} - - pos.noteGlowSize, 20).value} - onChange={(value) => - handleBatchStyleChangeComplete('noteGlowSize', value) - } - suffix="px" - min={0} - max={50} - allowDecimal - decimalScale={1} - isMixed={getMixedValue((pos) => pos.noteGlowSize, 20).isMixed} - /> - + {/* 글로우 색상 */} + + + + + {/* 글로우 크기 */} + + pos.noteGlowSize, 20).value} + onChange={(value) => + handleBatchStyleChangeComplete('noteGlowSize', value) + } + suffix="px" + min={0} + max={50} + allowDecimal + decimalScale={1} + isMixed={getMixedValue((pos) => pos.noteGlowSize, 20).isMixed} + /> + + > ); }; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx index 2abcf3ab..c611ed63 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx @@ -11,6 +11,7 @@ import type { } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import { PANEL_ROOT_CLASS } from '../panelChrome'; import { normalizeCounterSettings, createDefaultCounterSettings, @@ -522,21 +523,18 @@ export const BatchKeyLikePanel: React.FC = ({ }; return ( - + {/* 헤더 + 탭 영역 */} - + {/* 헤더 */} - + {selectedGroupInfo ? ( isRenaming ? ( setRenameValue(e.target.value)} onBlur={() => { @@ -558,7 +556,7 @@ export const BatchKeyLikePanel: React.FC = ({ ) : ( @@ -566,7 +564,7 @@ export const BatchKeyLikePanel: React.FC = ({ @@ -574,12 +572,12 @@ export const BatchKeyLikePanel: React.FC = ({ ) ) : ( - + {t('propertiesPanel.multiSelection') || '다중 선택'} )} {!selectedGroupInfo && ( - + ({selectedBatchStyleElements.length}) )} @@ -587,14 +585,14 @@ export const BatchKeyLikePanel: React.FC = ({ @@ -626,7 +624,7 @@ export const BatchKeyLikePanel: React.FC = ({ activeTab === TABS.STYLE ? '' : 'hidden' }`} > - + 0} @@ -639,7 +637,7 @@ export const BatchKeyLikePanel: React.FC = ({ label={t('propertiesPanel.graphShape') || 'Graph Shape'} > {graphTypeState.isMixed ? ( - + Mixed ) : null} @@ -655,8 +653,8 @@ export const BatchKeyLikePanel: React.FC = ({ {hasLineGraph && ( - - + + {t('propertiesPanel.graphShowAverageLine') || 'Show Average Line'} @@ -675,7 +673,7 @@ export const BatchKeyLikePanel: React.FC = ({ label={t('propertiesPanel.graphSpeed') || 'Graph Speed'} > {graphSpeedState.isMixed ? ( - + Mixed ) : null} @@ -703,7 +701,7 @@ export const BatchKeyLikePanel: React.FC = ({ label={t('propertiesPanel.graphColor') || 'Graph Color'} > {graphColorState.isMixed ? ( - + Mixed ) : null} @@ -720,14 +718,14 @@ export const BatchKeyLikePanel: React.FC = ({ /> - - + + {t('propertiesPanel.graphAnimation') || 'Graph Animation'} {graphAnimationState.isMixed ? ( - + Mixed ) : null} @@ -785,7 +783,7 @@ export const BatchKeyLikePanel: React.FC = ({ activeTab === TABS.NOTE ? '' : 'hidden' }`} > - + = ({ activeTab === TABS.COUNTER ? '' : 'hidden' }`} > - + = ({ batchCounterStrokeButtonRef={batchCounterStrokeButtonRef} isFillPickerOpen={batchPickerFor === 'fill'} isStrokePickerOpen={batchPickerFor === 'stroke'} - panelElement={panelElement} t={t} /> @@ -1104,19 +1101,16 @@ export const BatchGraphOnlyPanel: React.FC = ({ const batchGraphSpacing = getBatchSpacingValue(); return ( - - - + + + {selectedGroupInfo ? ( isRenaming ? ( setRenameValue(e.target.value)} onBlur={() => { @@ -1138,7 +1132,7 @@ export const BatchGraphOnlyPanel: React.FC = ({ ) : ( @@ -1146,7 +1140,7 @@ export const BatchGraphOnlyPanel: React.FC = ({ @@ -1154,12 +1148,12 @@ export const BatchGraphOnlyPanel: React.FC = ({ ) ) : ( - + {t('propertiesPanel.multiSelection') || '다중 선택'} )} {!selectedGroupInfo && ( - + ({selectedGraphElements.length}) )} @@ -1167,14 +1161,14 @@ export const BatchGraphOnlyPanel: React.FC = ({ @@ -1188,7 +1182,7 @@ export const BatchGraphOnlyPanel: React.FC = ({ ref={batchScrollRefFor(TABS.STYLE)} className="properties-panel-overlay-viewport" > - + = ({ label={t('propertiesPanel.graphShape') || 'Graph Shape'} > {graphTypeState.isMixed ? ( - + Mixed ) : null} @@ -1216,8 +1210,8 @@ export const BatchGraphOnlyPanel: React.FC = ({ {hasLineGraph && ( - - + + {t('propertiesPanel.graphShowAverageLine') || 'Show Average Line'} @@ -1236,7 +1230,7 @@ export const BatchGraphOnlyPanel: React.FC = ({ label={t('propertiesPanel.graphSpeed') || 'Graph Speed'} > {graphSpeedState.isMixed ? ( - + Mixed ) : null} @@ -1261,7 +1255,7 @@ export const BatchGraphOnlyPanel: React.FC = ({ label={t('propertiesPanel.graphColor') || 'Graph Color'} > {graphColorState.isMixed ? ( - + Mixed ) : null} @@ -1276,13 +1270,13 @@ export const BatchGraphOnlyPanel: React.FC = ({ /> - - + + {t('propertiesPanel.graphAnimation') || 'Graph Animation'} {graphAnimationState.isMixed ? ( - + Mixed ) : null} @@ -1472,19 +1466,16 @@ export const BatchKnobOnlyPanel: React.FC = ({ const batchKnobSpacing = getBatchSpacingValue(); return ( - - - + + + {selectedGroupInfo ? ( isRenaming ? ( setRenameValue(e.target.value)} onBlur={() => { @@ -1506,7 +1497,7 @@ export const BatchKnobOnlyPanel: React.FC = ({ ) : ( @@ -1514,7 +1505,7 @@ export const BatchKnobOnlyPanel: React.FC = ({ @@ -1522,12 +1513,12 @@ export const BatchKnobOnlyPanel: React.FC = ({ ) ) : ( - + {t('propertiesPanel.multiSelection') || '다중 선택'} )} {!selectedGroupInfo && ( - + ({selectedKnobElements.length}) )} @@ -1535,14 +1526,14 @@ export const BatchKnobOnlyPanel: React.FC = ({ @@ -1556,7 +1547,7 @@ export const BatchKnobOnlyPanel: React.FC = ({ ref={batchScrollRefFor(TABS.STYLE)} className="properties-panel-overlay-viewport" > - + = ({ label={t('propertiesPanel.knobSensitivity') || '민감도'} > {sensitivityState.isMixed ? ( - + Mixed ) : null} @@ -1588,13 +1579,13 @@ export const BatchKnobOnlyPanel: React.FC = ({ /> - - + + {t('propertiesPanel.knobReverse') || '방향 반전'} {reverseState.isMixed ? ( - + Mixed ) : null} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx index 9da2eed0..ea530f5d 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchStyleTabContent.tsx @@ -1,16 +1,22 @@ import React, { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; import type { KeyPosition } from '@src/types/key/keys'; import { PropertyRow, NumberInput, ColorInput, TextInput, - SectionDivider, + PropertySection, FontStyleToggle, } from '../index'; import Checkbox from '@components/main/common/Checkbox'; import FontPicker from '@components/main/Modal/content/pickers/FontPicker'; import SoundPicker from '@components/main/Modal/content/pickers/SoundPicker'; +import { usePanelNav } from '../PanelNavContext'; + +// 인-패널 서브 페이지 키 — 트리거 사이트별 유니크 +const FONT_PAGE_KEY = 'batch-style:font'; +const SOUND_PAGE_KEY = 'batch-style:sound'; const SPACING_COMMIT_DEBOUNCE_MS = 80; const SPACING_COMMIT_EPSILON = 0.0001; @@ -102,8 +108,9 @@ const BatchStyleTabContent: React.FC = ({ t, }) => { const [colorState, setColorState] = useState<'idle' | 'active'>('idle'); - const [showFontPicker, setShowFontPicker] = useState(false); - const [showSoundPicker, setShowSoundPicker] = useState(false); + // 인-패널 내비게이션 (폰트/사운드 서브 페이지) + const { activePageKey, renderPageKey, openPage, closePage, pageHost } = + usePanelNav(); // 간격 입력 세션 동안 첫 변경만 히스토리를 남기고 이후는 skipHistory로 묶는다. const lastSpacingRef = useRef(null); @@ -200,619 +207,639 @@ const BatchStyleTabContent: React.FC = ({ return ( <> - {/* 정렬 */} - - - {/* 수평 정렬 */} - - handleBatchAlign('left')} - className="w-[24px] h-[23px] bg-[#2A2A30] border border-[#3A3943] rounded-l-[7px] border-r-0 flex items-center justify-center hover:bg-[#353540] transition-colors" - title={t('propertiesPanel.alignLeft') || '왼쪽 정렬'} - > - - - - - - - handleBatchAlign('centerH')} - className="w-[24px] h-[23px] bg-[#2A2A30] border border-[#3A3943] border-r-0 flex items-center justify-center hover:bg-[#353540] transition-colors" - title={t('propertiesPanel.alignCenterH') || '수평 중앙 정렬'} - > - - - - - - - handleBatchAlign('right')} - className="w-[24px] h-[23px] bg-[#2A2A30] border border-[#3A3943] rounded-r-[7px] flex items-center justify-center hover:bg-[#353540] transition-colors" - title={t('propertiesPanel.alignRight') || '오른쪽 정렬'} - > - - - - - - + + {/* 정렬 */} + + + {/* 수평 정렬 */} + + handleBatchAlign('left')} + className="w-[24px] h-[23px] bg-inset rounded-l-[7px] border-r-0 flex items-center justify-center hover:bg-surface-hover transition-colors" + title={t('propertiesPanel.alignLeft') || '왼쪽 정렬'} + > + + + + + + + handleBatchAlign('centerH')} + className="w-[24px] h-[23px] bg-inset border-r-0 flex items-center justify-center hover:bg-surface-hover transition-colors" + title={t('propertiesPanel.alignCenterH') || '수평 중앙 정렬'} + > + + + + + + + handleBatchAlign('right')} + className="w-[24px] h-[23px] bg-inset rounded-r-[7px] flex items-center justify-center hover:bg-surface-hover transition-colors" + title={t('propertiesPanel.alignRight') || '오른쪽 정렬'} + > + + + + + + + + {/* 수직 정렬 */} + + handleBatchAlign('top')} + className="w-[24px] h-[23px] bg-inset rounded-l-[7px] border-r-0 flex items-center justify-center hover:bg-surface-hover transition-colors" + title={t('propertiesPanel.alignTop') || '위쪽 정렬'} + > + + + + + + + handleBatchAlign('centerV')} + className="w-[24px] h-[23px] bg-inset border-r-0 flex items-center justify-center hover:bg-surface-hover transition-colors" + title={t('propertiesPanel.alignCenterV') || '수직 중앙 정렬'} + > + + + + + + + handleBatchAlign('bottom')} + className="w-[24px] h-[23px] bg-inset rounded-r-[7px] flex items-center justify-center hover:bg-surface-hover transition-colors" + title={t('propertiesPanel.alignBottom') || '아래쪽 정렬'} + > + + + + + + + - {/* 수직 정렬 */} - + + + {/* 분배 */} + + handleBatchAlign('top')} - className="w-[24px] h-[23px] bg-[#2A2A30] border border-[#3A3943] rounded-l-[7px] border-r-0 flex items-center justify-center hover:bg-[#353540] transition-colors" - title={t('propertiesPanel.alignTop') || '위쪽 정렬'} + onClick={() => handleBatchDistribute('horizontal')} + disabled={selectedCount < 3} + className={`w-[24px] h-[23px] bg-inset rounded-md flex items-center justify-center transition-colors ${ + selectedCount < 3 + ? 'opacity-40 cursor-not-allowed' + : 'hover:bg-surface-hover' + }`} + title={t('propertiesPanel.distributeH') || '수평 분배'} > - - - - handleBatchAlign('centerV')} - className="w-[24px] h-[23px] bg-[#2A2A30] border border-[#3A3943] border-r-0 flex items-center justify-center hover:bg-[#353540] transition-colors" - title={t('propertiesPanel.alignCenterV') || '수직 중앙 정렬'} - > - - - handleBatchAlign('bottom')} - className="w-[24px] h-[23px] bg-[#2A2A30] border border-[#3A3943] rounded-r-[7px] flex items-center justify-center hover:bg-[#353540] transition-colors" - title={t('propertiesPanel.alignBottom') || '아래쪽 정렬'} + onClick={() => handleBatchDistribute('vertical')} + disabled={selectedCount < 3} + className={`w-[24px] h-[23px] bg-inset rounded-md flex items-center justify-center transition-colors ${ + selectedCount < 3 + ? 'opacity-40 cursor-not-allowed' + : 'hover:bg-surface-hover' + }`} + title={t('propertiesPanel.distributeV') || '수직 분배'} > - - - + - {/* 분배 */} - - - handleBatchDistribute('horizontal')} - disabled={selectedCount < 3} - className={`w-[24px] h-[23px] bg-[#2A2A30] border border-[#3A3943] rounded-[7px] flex items-center justify-center transition-colors ${ - selectedCount < 3 - ? 'opacity-40 cursor-not-allowed' - : 'hover:bg-[#353540]' - }`} - title={t('propertiesPanel.distributeH') || '수평 분배'} - > - - - - - - - handleBatchDistribute('vertical')} - disabled={selectedCount < 3} - className={`w-[24px] h-[23px] bg-[#2A2A30] border border-[#3A3943] rounded-[7px] flex items-center justify-center transition-colors ${ - selectedCount < 3 - ? 'opacity-40 cursor-not-allowed' - : 'hover:bg-[#353540]' - }`} - title={t('propertiesPanel.distributeV') || '수직 분배'} - > - - - - - - - - - - {/* 간격 */} - - - - - {/* 크기 */} - - 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} - /> - - - {/* 커스텀 이미지 */} - - - {t('propertiesPanel.configure') || '설정하기'} - - - - {!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} - setShowFontPicker(!showFontPicker)} + {/* 테두리 두께 */} + + {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} + /> + + + {/* 커스텀 이미지 */} + + + {t('propertiesPanel.configure') || '설정하기'} + + + + + {(!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} + { + if (activePageKey === FONT_PAGE_KEY) closePage(); + else openPage(FONT_PAGE_KEY); + }} + > + {t('propertiesPanel.configure') || '설정하기'} + + - {/* 글꼴 색상 */} - - {( - 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} setShowSoundPicker((prev) => !prev)} + className={`px-[8px] h-[23px] bg-fill hover:bg-fill-hover active:bg-fill-active transition-colors duration-fast rounded-md flex items-center justify-center ${ + activePageKey === SOUND_PAGE_KEY ? 'shadow-focus-ring' : '' + } text-fg text-body`} + onClick={() => { + if (activePageKey === SOUND_PAGE_KEY) closePage(); + else openPage(SOUND_PAGE_KEY); + }} > {t('propertiesPanel.configure') || '설정하기'} @@ -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 = ({ }) => ( {children} @@ -59,22 +58,34 @@ const LayerPanelTabs: React.FC = ({ activeTab, onTabChange, t, -}) => ( - - onTabChange(LAYER_PANEL_TABS.LAYER)} - > - {t('propertiesPanel.tabLayer') || '레이어'} - - onTabChange(LAYER_PANEL_TABS.GRID)} - > - {t('propertiesPanel.tabGrid') || '그리드'} - - -); +}) => { + const activeIndex = activeTab === LAYER_PANEL_TABS.GRID ? 1 : 0; + + return ( + + + onTabChange(LAYER_PANEL_TABS.LAYER)} + > + {t('propertiesPanel.tabLayer') || '레이어'} + + onTabChange(LAYER_PANEL_TABS.GRID)} + > + {t('propertiesPanel.tabGrid') || '그리드'} + + + ); +}; // ============================================================================ // 레이어 패널 컴포넌트 @@ -108,15 +119,12 @@ const LayerPanel: React.FC = ({ }; return ( - + {/* 헤더 + 탭 영역 */} - + {/* 헤더 */} - - + + {t('propertiesPanel.canvas') || 'Canvas'} @@ -124,10 +132,10 @@ const LayerPanel: React.FC = ({ = ({ {/* 패널 닫기 버튼 */} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx index 9017daa3..75330324 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx @@ -521,7 +521,7 @@ const LayerTabContent: React.FC = ({ > {layerItems.length === 0 ? ( - + {t('propertiesPanel.noLayers') || 'No layers'} @@ -553,27 +553,27 @@ const LayerTabContent: React.FC = ({ ${isBeingDragged ? 'opacity-30' : ''} ${ isSelected - ? 'bg-[#3B82F6]/20 text-[#DBDEE8]' + ? 'bg-accent-muted text-fg' : dnd.isDragging - ? 'text-[#9B9DA5]' - : 'hover:bg-[#2A2A30] text-[#9B9DA5]' + ? 'text-fg-muted' + : 'hover:bg-surface-hover text-fg-muted' } `} > {/* 아이템 드래그 드롭 인디케이터 */} {dnd.draggedItemId && dnd.dragOverItemDisplayIndex === displayIndex && ( - + )} {dnd.draggedItemId && dnd.dragOverHeaderBottomGroupId === gh.groupId && ( - + )} {/* 그룹 드래그 드롭 인디케이터 */} {dnd.draggedGroupId && dnd.dragOverDisplayIndex === displayIndex && dnd.draggedGroupId !== gh.groupId && ( - + )} {/* 접기/펼치기 토글 */} = ({ actions.setRenameValue(e.target.value)} onBlur={() => { @@ -625,7 +625,7 @@ const LayerTabContent: React.FC = ({ onDoubleClick={(e) => e.stopPropagation()} /> ) : ( - + {gh.groupName} )} @@ -645,7 +645,7 @@ const LayerTabContent: React.FC = ({ e.stopPropagation(); actions.handleToggleGroupVisibility(e, gh.groupId); }} - className={`flex-shrink-0 w-[28px] h-[28px] flex items-center justify-center rounded-[6px] hover:bg-[#4C4D53] cursor-pointer ${ + className={`flex-shrink-0 w-[28px] h-[28px] flex items-center justify-center rounded-[6px] cursor-pointer ${ gh.allHidden ? '' : 'opacity-0 group-hover:opacity-60 hover:!opacity-100' @@ -695,10 +695,10 @@ const LayerTabContent: React.FC = ({ ${isInDraggedGroup ? 'opacity-30' : ''} ${ isItemSelected(item) - ? 'bg-[#3B82F6]/20 text-[#DBDEE8]' + ? 'bg-accent-muted text-fg' : dnd.isDragging - ? 'text-[#8B8D95]' - : 'hover:bg-[#2A2A30] text-[#8B8D95]' + ? 'text-fg-muted' + : 'hover:bg-surface-hover text-fg-muted' } `} > @@ -706,12 +706,12 @@ const LayerTabContent: React.FC = ({ {dnd.draggedItemId && dnd.dragOverItemDisplayIndex === displayIndex && !dnd.draggedItemIdsRef.current.includes(item.id) && ( - + )} {dnd.draggedGroupId && dnd.dragOverDisplayIndex === displayIndex && item.groupId !== dnd.draggedGroupId && ( - + )} {/* 아이콘 */} @@ -734,7 +734,7 @@ const LayerTabContent: React.FC = ({ actions.setRenameValue(e.target.value)} onBlur={() => { @@ -761,7 +761,7 @@ const LayerTabContent: React.FC = ({ onDoubleClick={(e) => e.stopPropagation()} /> ) : ( - + {item.name} )} @@ -783,7 +783,7 @@ const LayerTabContent: React.FC = ({ ? t('propertiesPanel.showLayer') || 'Show' : t('propertiesPanel.hideLayer') || 'Hide' } - className={`flex-shrink-0 w-[28px] h-[28px] flex items-center justify-center rounded-[6px] hover:bg-[#4C4D53] cursor-pointer ${ + className={`flex-shrink-0 w-[28px] h-[28px] flex items-center justify-center rounded-[6px] cursor-pointer ${ item.hidden ? '' : 'opacity-0 group-hover:opacity-60 hover:!opacity-100' @@ -806,11 +806,11 @@ const LayerTabContent: React.FC = ({ {/* 마지막 아이템 뒤 드롭 인디케이터 */} {dnd.draggedItemId && dnd.dragOverItemDisplayIndex === displayItems.length && ( - + )} {dnd.draggedGroupId && dnd.dragOverDisplayIndex === displayItems.length && ( - + )} )} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/panelChrome.ts b/src/renderer/components/main/Grid/PropertiesPanel/panelChrome.ts new file mode 100644 index 00000000..eca4489c --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/panelChrome.ts @@ -0,0 +1,7 @@ +// 사이드 패널 공통 크롬 — 재질/레이아웃 변경은 여기 한 곳에서 +// 프레임이 글래스 재질을 소유하고, 페이지 전환 시 콘텐츠 레이어만 슬라이드 +export const SIDE_PANEL_FRAME_CLASS = + 'absolute right-0 top-0 bottom-0 w-[240px] bg-glass-panel backdrop-blur-[24px] shadow-elevation-panel z-30'; + +// 구상 패널 루트 — 프레임을 꽉 채우는 투명 레이어 +export const PANEL_ROOT_CLASS = 'absolute inset-0 flex flex-col'; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx index b481cfa3..977f6e71 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx @@ -1,5 +1,6 @@ /* eslint-disable react-hooks/set-state-in-effect */ import React, { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import type { CounterTabContentProps } from '../types'; import type { KeyCounterAnimationSettings, @@ -10,15 +11,20 @@ import { PropertyRow, FontStyleToggle, NumberInput, - SectionDivider, + PropertySection, } from '../PropertyInputs'; import Checkbox from '@components/main/common/Checkbox'; import Dropdown from '@components/main/common/Dropdown'; import ColorPicker from '@components/main/Modal/content/pickers/ColorPicker'; import FontPicker from '@components/main/Modal/content/pickers/FontPicker'; import CounterAnimationPicker from '@components/main/Modal/content/pickers/CounterAnimationPicker'; +import { usePanelNav } from '../PanelNavContext'; -type PickerTarget = 'fill' | 'stroke' | 'font' | null; +// 인-패널 서브 페이지 키 — 트리거 사이트별 유니크 +const FONT_PAGE_KEY = 'single-counter:font'; +const ANIMATION_PAGE_KEY = 'single-counter:animation'; + +type PickerTarget = 'fill' | 'stroke' | null; type ColorState = 'idle' | 'active'; const CounterTabContent: React.FC = ({ @@ -36,9 +42,12 @@ const CounterTabContent: React.FC = ({ const animationBtnRef = useRef(null); const [pickerFor, setPickerFor] = useState(null); - const pickerOpen = pickerFor !== null && pickerFor !== 'font'; + const pickerOpen = pickerFor !== null; const [colorState, setColorState] = useState('idle'); - const [showAnimationPicker, setShowAnimationPicker] = useState(false); + + // 인-패널 내비게이션 (폰트/애니메이션 서브 페이지) + const { activePageKey, renderPageKey, openPage, closePage, pageHost } = + usePanelNav(); const counterSettings = normalizeCounterSettings(keyPosition.counter); @@ -161,220 +170,231 @@ const CounterTabContent: React.FC = ({ return ( <> - {/* 카운터 사용 */} - - - {t('counterSetting.counterEnabled') || '카운터 표시'} - - - handleCounterUpdate({ enabled: !counterSettings.enabled }) - } - /> - - - - - {/* 배치 영역 */} - - - handleCounterUpdate({ placement: value as 'inside' | 'outside' }) - } - /> - - - {/* 정렬 방향 */} - - - handleCounterUpdate({ - align: value as 'top' | 'bottom' | 'left' | 'right', - }) - } - /> - + + {/* 카운터 사용 */} + + + {t('counterSetting.counterEnabled') || '카운터 표시'} + + + handleCounterUpdate({ enabled: !counterSettings.enabled }) + } + /> + + - {/* 정렬 방식 (내부 배치 전용) */} - {counterSettings.placement === 'inside' && ( - + + {/* 배치 영역 */} + + handleCounterUpdate({ placement: value as 'inside' | 'outside' }) + } + /> + + + {/* 정렬 방향 */} + + handleCounterUpdate({ - alignMode: value as 'center' | 'between', + align: value as 'top' | 'bottom' | 'left' | 'right', }) } /> - )} - {/* 간격 */} - - handleCounterUpdate({ gap: value })} - suffix="px" - min={0} - max={9999} - width="54px" - /> - - - - - {/* 채우기 색상 */} - - handlePickerToggle('fill')} - className={`w-[23px] h-[23px] rounded-[7px] border-[1px] overflow-hidden cursor-pointer transition-colors flex-shrink-0 ${ - pickerFor === 'fill' - ? 'border-[#459BF8]' - : 'border-[#3A3943] hover:border-[#505058]' - }`} - style={{ - backgroundColor: getDisplayColor( - activeColorFor('fill', colorState), - ), - }} - /> - - - {/* 외곽선 색상 */} - - handlePickerToggle('stroke')} - className={`w-[23px] h-[23px] rounded-[7px] border-[1px] overflow-hidden cursor-pointer transition-colors flex-shrink-0 ${ - pickerFor === 'stroke' - ? 'border-[#459BF8]' - : 'border-[#3A3943] hover:border-[#505058]' - }`} - style={{ - backgroundColor: getDisplayColor( - activeColorFor('stroke', colorState), - ), - }} - /> - - - - - {/* 폰트 */} - - handlePickerToggle('font')} - > - {t('propertiesPanel.configure') || '설정하기'} - - - - {/* 폰트 크기 */} - - 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, - }, - }) - } - /> - - - - setShowAnimationPicker((prev) => !prev)} - > - {t('propertiesPanel.configure') || '설정하기'} - - - - {pickerFor && pickerFor !== 'font' && ( + {/* 정렬 방식 (내부 배치 전용) */} + {counterSettings.placement === 'inside' && ( + + + handleCounterUpdate({ + alignMode: value as 'center' | 'between', + }) + } + /> + + )} + + {/* 간격 */} + + handleCounterUpdate({ gap: value })} + suffix="px" + min={0} + max={9999} + width="54px" + /> + + + + + {/* 채우기 색상 */} + + handlePickerToggle('fill')} + className={`w-[23px] h-[23px] rounded-md border-[1px] overflow-hidden cursor-pointer transition-colors flex-shrink-0 ${ + pickerFor === 'fill' ? 'border-accent' : 'border-line' + }`} + style={{ + backgroundColor: getDisplayColor( + activeColorFor('fill', colorState), + ), + }} + /> + + + {/* 외곽선 색상 */} + + handlePickerToggle('stroke')} + className={`w-[23px] h-[23px] rounded-md border-[1px] overflow-hidden cursor-pointer transition-colors flex-shrink-0 ${ + pickerFor === 'stroke' ? 'border-accent' : 'border-line' + }`} + style={{ + backgroundColor: getDisplayColor( + activeColorFor('stroke', colorState), + ), + }} + /> + + + + + {/* 폰트 */} + + { + setPickerFor(null); + if (activePageKey === FONT_PAGE_KEY) closePage(); + else openPage(FONT_PAGE_KEY); + }} + > + {t('propertiesPanel.configure') || '설정하기'} + + + + {/* 폰트 크기 */} + + 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, + }, + }) + } + /> + + + + { + setPickerFor(null); + if (activePageKey === ANIMATION_PAGE_KEY) closePage(); + else openPage(ANIMATION_PAGE_KEY); + }} + > + {t('propertiesPanel.configure') || '설정하기'} + + + + + {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), + ) + } + /> + - {/* 노트 색상 */} - - handlePickerToggle('note')} - className={`w-[23px] h-[23px] rounded-[7px] border-[1px] overflow-hidden cursor-pointer transition-colors flex-shrink-0 ${ - pickerFor === 'note' - ? 'border-[#459BF8]' - : 'border-[#3A3943] hover:border-[#505058]' - }`} - style={getNoteColorDisplay().style} - /> - + {/* Y축 자동 보정 */} + + + {t('keySetting.noteAutoYCorrection') || 'Y축 자동 보정'} + + + handleStyleChangeComplete( + 'noteAutoYCorrection', + !(keyPosition.noteAutoYCorrection ?? true), + ) + } + /> + + - {/* 노트 테두리 색상 + 방향 */} - - - handlePickerToggle('border')} - className={`w-[23px] h-[23px] rounded-[7px] border-[1px] overflow-hidden cursor-pointer transition-colors flex-shrink-0 ${ - pickerFor === 'border' - ? 'border-[#459BF8]' - : 'border-[#3A3943] hover:border-[#505058]' - }`} - style={{ backgroundColor: borderColor }} + + {/* 오프셋 */} + + + handleStyleChangeComplete('noteOffsetX', value) + } + prefix="X" + allowNegative + allowDecimal + decimalScale={1} + min={NOTE_SETTINGS_CONSTRAINTS.noteOffsetX.min} + max={NOTE_SETTINGS_CONSTRAINTS.noteOffsetX.max} + placeholder="0" /> - - {(keyPosition.noteBorderSide ?? 'all') === 'all' && ( - - )} - {(keyPosition.noteBorderSide ?? 'all') === 'vertical' && ( - <> - - - > - )} - {(keyPosition.noteBorderSide ?? 'all') === 'horizontal' && ( - <> - - - > - )} - + + handleStyleChangeComplete('noteOffsetY', value) } - align="right" + 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( - 'noteBorderSide', - value as 'all' | 'vertical' | 'horizontal', + 'noteAlignment', + value as 'left' | 'center' | 'right', + ) + } + /> + + + + + {/* 노트 색상 */} + + handlePickerToggle('note')} + className={`w-[23px] h-[23px] rounded-md border-[1px] overflow-hidden cursor-pointer transition-colors flex-shrink-0 ${ + pickerFor === 'note' ? 'border-accent' : 'border-line' + }`} + style={getNoteColorDisplay().style} + /> + + + {/* 노트 테두리 색상 + 방향 */} + + + handlePickerToggle('border')} + className={`w-[23px] h-[23px] rounded-md border-[1px] overflow-hidden cursor-pointer transition-colors flex-shrink-0 ${ + pickerFor === 'border' ? 'border-accent' : 'border-line' + }`} + style={{ backgroundColor: borderColor }} + /> + + {(keyPosition.noteBorderSide ?? 'all') === 'all' && ( + + )} + {(keyPosition.noteBorderSide ?? 'all') === 'vertical' && ( + <> + + + > + )} + {(keyPosition.noteBorderSide ?? 'all') === 'horizontal' && ( + <> + + + > + )} + + } + align="right" + options={[ + { + label: t('keySetting.borderSideAll') || '전체', + value: 'all', + }, + { + label: t('keySetting.borderSideVertical') || '수직', + value: 'vertical', + }, + { + label: t('keySetting.borderSideHorizontal') || '수평', + value: 'horizontal', + }, + ]} + value={keyPosition.noteBorderSide ?? 'all'} + onChange={(value) => + handleStyleChangeComplete( + 'noteBorderSide', + value as 'all' | 'vertical' | 'horizontal', + ) + } + /> + + + + {/* 노트 테두리 두께 */} + + + 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), - ) - } - /> - - - {/* 글로우 색상/크기/투명도 */} - - handlePickerToggle('glow')} - className={`w-[23px] h-[23px] rounded-[7px] border-[1px] overflow-hidden cursor-pointer transition-colors flex-shrink-0 ${ - pickerFor === 'glow' - ? 'border-[#459BF8]' - : 'border-[#3A3943] hover:border-[#505058]' - }`} - style={getGlowColorDisplay().style} - /> - - - - handleStyleChangeComplete('noteGlowSize', value)} - suffix="px" - min={0} - max={50} - allowDecimal - decimalScale={1} - /> - + + {/* 글로우 색상/크기/투명도 */} + + handlePickerToggle('glow')} + className={`w-[23px] h-[23px] rounded-md border-[1px] overflow-hidden cursor-pointer transition-colors flex-shrink-0 ${ + pickerFor === 'glow' ? 'border-accent' : 'border-line' + }`} + style={getGlowColorDisplay().style} + /> + + + + + handleStyleChangeComplete('noteGlowSize', value) + } + suffix="px" + min={0} + max={50} + allowDecimal + decimalScale={1} + /> + + {/* 통합 ColorPicker - 단일 인스턴스로 깜빡임 없이 전환 */} {pickerFor && ( diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx index e6f93ddf..8a149e8e 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx @@ -15,12 +15,13 @@ import type { PluginDisplayElementInternal, } from '@src/types/plugin/api'; import type { KeyInfo } from '@utils/core/KeyMaps'; +import { PANEL_ROOT_CLASS } from '../panelChrome'; import { PropertyRow, NumberInput, TextInput, ColorInput, - SectionDivider, + PropertySection, SidebarToggleIcon, ModeToggleIcon, Tabs, @@ -133,25 +134,22 @@ export const PluginSelectionPanel: React.FC = ({ t, }) => { return ( - - - + + + {pluginTitle} @@ -163,9 +161,9 @@ export const PluginSelectionPanel: React.FC = ({ ref={setPluginScrollRef} className="properties-panel-overlay-viewport" > - + {isPluginResizable && ( - <> + = ({ decimalScale={1} /> - - > + )} {!hasSinglePluginSelection && ( - + {t('propertiesPanel.pluginMultiSelection') || '플러그인 요소는 한 번에 하나만 편집할 수 있습니다.'} )} {hasSinglePluginSelection && showModalHint && ( - + {t('propertiesPanel.pluginModalHint') || '이 플러그인은 설정 모달을 사용합니다. 요소를 클릭해 설정하세요.'} )} - {showSettings && - renderPluginSettingsForm( - selectedPluginDefinition?.settings, - resolvedPluginSettings, - selectedPluginDefinition?.messages, - `plugin-element-${selectedPluginElement?.fullId ?? 'unknown'}`, - handlePluginSettingChange, - { wrap: false }, - )} + {showSettings && ( + + {renderPluginSettingsForm( + selectedPluginDefinition?.settings, + resolvedPluginSettings, + selectedPluginDefinition?.messages, + `plugin-element-${ + selectedPluginElement?.fullId ?? 'unknown' + }`, + handlePluginSettingChange, + { wrap: false }, + )} + + )} = ({ const graphTitle = singleGraphPosition.layerName || graphDefaultTitle; return ( - - + + {isRenaming ? ( setRenameValue(e.target.value)} onBlur={() => { @@ -347,7 +346,7 @@ export const SingleGraphPanel: React.FC = ({ ) : ( @@ -355,7 +354,7 @@ export const SingleGraphPanel: React.FC = ({ @@ -365,14 +364,14 @@ export const SingleGraphPanel: React.FC = ({ @@ -384,245 +383,249 @@ export const SingleGraphPanel: React.FC = ({ ref={singleScrollRefFor(TABS.STYLE)} className="properties-panel-overlay-viewport" > - - - - handleGraphUpdate({ - index: singleGraphIndex, - dx: value, - }) - } - prefix="X" - min={-9999} - max={9999} - /> - - handleGraphUpdate({ - index: singleGraphIndex, - dy: value, - }) - } - prefix="Y" - min={-9999} - max={9999} - /> - - - - - handleGraphUpdate({ - index: singleGraphIndex, - width: Math.max(20, value), - }) - } - prefix="W" - min={20} - max={9999} - /> - - handleGraphUpdate({ - index: singleGraphIndex, - height: Math.max(20, value), - }) - } - prefix="H" - min={20} - max={9999} - /> - + + + + + handleGraphUpdate({ + index: singleGraphIndex, + dx: value, + }) + } + prefix="X" + min={-9999} + max={9999} + /> + + handleGraphUpdate({ + index: singleGraphIndex, + dy: value, + }) + } + prefix="Y" + min={-9999} + max={9999} + /> + + + + + handleGraphUpdate({ + index: singleGraphIndex, + width: Math.max(20, value), + }) + } + prefix="W" + min={20} + max={9999} + /> + + handleGraphUpdate({ + index: singleGraphIndex, + height: Math.max(20, value), + }) + } + prefix="H" + min={20} + max={9999} + /> + + - + + + + handleGraphUpdate({ + index: singleGraphIndex, + graphType: value as GraphItemType, + }) + } + /> + - - - handleGraphUpdate({ - index: singleGraphIndex, - graphType: value as GraphItemType, - }) - } - /> - + {(singleGraphPosition.graphType || 'line') === 'line' && ( + + + {t('propertiesPanel.graphShowAverageLine') || + 'Show Average Line'} + + + handleGraphUpdate({ + index: singleGraphIndex, + showAvgLine: !(singleGraphPosition.showAvgLine ?? true), + }) + } + /> + + )} + + + { + const clamped = Math.max(500, Math.min(5000, value)); + const snapped = Math.round(clamped / 100) * 100; + handleGraphUpdate({ + index: singleGraphIndex, + graphSpeed: snapped, + }); + }} + min={500} + max={5000} + suffix="ms" + /> + - {(singleGraphPosition.graphType || 'line') === 'line' && ( - - - {t('propertiesPanel.graphShowAverageLine') || - 'Show Average Line'} + + {}} + onChangeComplete={(value) => + handleGraphUpdate({ + index: singleGraphIndex, + graphColor: value, + }) + } + colorId={`graph-color-${selectedKeyType}-${singleGraphIndex}`} + panelElement={panelElement} + /> + + + + + {t('propertiesPanel.graphAnimation') || 'Graph Animation'} handleGraphUpdate({ index: singleGraphIndex, - showAvgLine: !(singleGraphPosition.showAvgLine ?? true), + graphAnimationEnabled: !( + singleGraphPosition.graphAnimationEnabled ?? true + ), }) } /> - )} + - - { - const clamped = Math.max(500, Math.min(5000, value)); - const snapped = Math.round(clamped / 100) * 100; - handleGraphUpdate({ - index: singleGraphIndex, - graphSpeed: snapped, - }); - }} - min={500} - max={5000} - suffix="ms" - /> - - - - {}} - onChangeComplete={(value) => - handleGraphUpdate({ - index: singleGraphIndex, - graphColor: value, - }) + + - - - - - {t('propertiesPanel.graphAnimation') || 'Graph Animation'} - - - handleGraphUpdate({ - index: singleGraphIndex, - graphAnimationEnabled: !( - singleGraphPosition.graphAnimationEnabled ?? true - ), - }) - } - /> - - - - - - {}} - onChangeComplete={(value) => - handleGraphUpdate({ - index: singleGraphIndex, - backgroundColor: value, - }) - } - colorId={`graph-bg-color-${selectedKeyType}-${singleGraphIndex}`} - panelElement={panelElement} - /> - + > + {}} + onChangeComplete={(value) => + handleGraphUpdate({ + index: singleGraphIndex, + backgroundColor: value, + }) + } + colorId={`graph-bg-color-${selectedKeyType}-${singleGraphIndex}`} + panelElement={panelElement} + /> + - - {}} - onChangeComplete={(value) => - handleGraphUpdate({ - index: singleGraphIndex, - borderColor: value, - }) - } - colorId={`graph-border-color-${selectedKeyType}-${singleGraphIndex}`} - panelElement={panelElement} - /> - + + {}} + onChangeComplete={(value) => + handleGraphUpdate({ + index: singleGraphIndex, + borderColor: value, + }) + } + colorId={`graph-border-color-${selectedKeyType}-${singleGraphIndex}`} + panelElement={panelElement} + /> + - - - handleGraphUpdate({ - index: singleGraphIndex, - borderWidth: Math.max(0, Math.min(20, value)), - }) - } - min={0} - max={20} - suffix="px" - /> - + + + handleGraphUpdate({ + index: singleGraphIndex, + borderWidth: Math.max(0, Math.min(20, value)), + }) + } + min={0} + max={20} + suffix="px" + /> + - - - handleGraphUpdate({ - index: singleGraphIndex, - borderRadius: Math.max(0, Math.min(100, value)), - }) - } - min={0} - max={100} - suffix="px" - /> - + + + handleGraphUpdate({ + index: singleGraphIndex, + borderRadius: Math.max(0, Math.min(100, value)), + }) + } + min={0} + max={100} + suffix="px" + /> + - - setShowGraphImagePicker(!showGraphImagePicker)} + - {t('propertiesPanel.configure') || 'Configure'} - - + setShowGraphImagePicker(!showGraphImagePicker)} + > + {t('propertiesPanel.configure') || 'Configure'} + + + {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 = ({ @@ -1012,14 +1012,14 @@ export const SingleKnobPanel: React.FC = ({ @@ -1032,217 +1032,219 @@ export const SingleKnobPanel: React.FC = ({ ref={singleScrollRefFor(TABS.STYLE)} className="properties-panel-overlay-viewport" > - + {/* 노브 매핑 (키 매핑과 동일한 라벨/버튼 구조) */} - - setCapturing((v) => !v)} - className={`flex items-center justify-center h-[23px] min-w-[0px] px-[8.5px] bg-[#2A2A30] rounded-[7px] border-[1px] ${ - capturing ? 'border-[#459BF8]' : 'border-[#3A3943]' - } text-[#DBDEE8] text-style-2`} - title={singleKnobPosition.axisId || ''} - > -
+
{t('propertiesPanel.pluginNoSettings') || '설정할 항목이 없습니다.'}
{labelText}
{label}
- {t('counterSetting.counterEnabled') || '카운터 표시'} -
+ {t('counterSetting.counterEnabled') || '카운터 표시'} +
- {t('counterSetting.animationEnabled') || '카운터 애니메이션'} -
+ {t('counterSetting.animationEnabled') || '카운터 애니메이션'} +
- {t('keySetting.noteEffectEnabled') || '노트 효과 표시'} -
- {t('keySetting.noteAutoYCorrection') || 'Y축 자동 보정'} -
+ {t('keySetting.noteEffectEnabled') || '노트 효과 표시'} +
+ {t('keySetting.noteAutoYCorrection') || 'Y축 자동 보정'} +
- {t('keySetting.noteGlow') || '글로우 효과'} -
+ {t('keySetting.noteGlow') || '글로우 효과'} +
{t('propertiesPanel.graphShowAverageLine') || 'Show Average Line'}
{t('propertiesPanel.graphAnimation') || 'Graph Animation'}
{t('propertiesPanel.knobReverse') || '방향 반전'}
{t('propertiesPanel.useInlineStyles') || '인라인 스타일 우선'}
{t('propertiesPanel.noLayers') || 'No layers'}
{t('propertiesPanel.pluginMultiSelection') || '플러그인 요소는 한 번에 하나만 편집할 수 있습니다.'}
{t('propertiesPanel.pluginModalHint') || '이 플러그인은 설정 모달을 사용합니다. 요소를 클릭해 설정하세요.'}
+ {t('propertiesPanel.graphShowAverageLine') || + 'Show Average Line'} +
- {t('propertiesPanel.graphShowAverageLine') || - 'Show Average Line'} + + {}} + onChangeComplete={(value) => + handleGraphUpdate({ + index: singleGraphIndex, + graphColor: value, + }) + } + colorId={`graph-color-${selectedKeyType}-${singleGraphIndex}`} + panelElement={panelElement} + /> + + +
+ {t('propertiesPanel.graphAnimation') || 'Graph Animation'}
- {t('propertiesPanel.graphAnimation') || 'Graph Animation'} -
+ + + {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 = ({ @@ -1012,14 +1012,14 @@ export const SingleKnobPanel: React.FC = ({ @@ -1032,217 +1032,219 @@ export const SingleKnobPanel: React.FC = ({ ref={singleScrollRefFor(TABS.STYLE)} className="properties-panel-overlay-viewport" > - + {/* 노브 매핑 (키 매핑과 동일한 라벨/버튼 구조) */} - - setCapturing((v) => !v)} - className={`flex items-center justify-center h-[23px] min-w-[0px] px-[8.5px] bg-[#2A2A30] rounded-[7px] border-[1px] ${ - capturing ? 'border-[#459BF8]' : 'border-[#3A3943]' - } text-[#DBDEE8] text-style-2`} - title={singleKnobPosition.axisId || ''} - > -