diff --git a/docs/content/en/api-reference/overlay/page.mdx b/docs/content/en/api-reference/overlay/page.mdx index 839cef289..2c98dcb92 100644 --- a/docs/content/en/api-reference/overlay/page.mdx +++ b/docs/content/en/api-reference/overlay/page.mdx @@ -45,13 +45,14 @@ await dmn.overlay.setLock(false); Sets the fixed point used when resizing the overlay. - Supported values: `top-left`, `top-right`, `bottom-left`, `bottom-right`, and - `center`. + Supported values: `top-left`, `top-right`, `bottom-left`, `bottom-right`, + `center`, and `fixed-position`. ```javascript await dmn.overlay.setAnchor('top-left'); await dmn.overlay.setAnchor('center'); +await dmn.overlay.setAnchor('fixed-position'); // Preserve existing elements' screen positions await dmn.overlay.setAnchor('bottom-right'); ``` diff --git a/docs/content/en/api-reference/settings/page.mdx b/docs/content/en/api-reference/settings/page.mdx index d25e6b124..b252bbae0 100644 --- a/docs/content/en/api-reference/settings/page.mdx +++ b/docs/content/en/api-reference/settings/page.mdx @@ -34,7 +34,8 @@ interface SettingsState { | 'top-right' | 'bottom-left' | 'bottom-right' - | 'center'; + | 'center' + | 'fixed-position'; keyCounterEnabled: boolean; gridSettings: { alignmentGuides: boolean; diff --git a/docs/content/en/guide/settings/page.mdx b/docs/content/en/guide/settings/page.mdx index 73a50c547..cf46153a9 100644 --- a/docs/content/en/guide/settings/page.mdx +++ b/docs/content/en/guide/settings/page.mdx @@ -20,6 +20,8 @@ Select the program interface language: Set the background color of the overlay window. Default is transparent. +If saving fails, the background returns to the saved color while the popup retains your input. After resolving the cause, click **Save again** to retry the same draft. The draft survives closing and reopening the popup, but is lost when you exit the app. + Using window capture in OBS maintains transparent background. No chroma key needed. @@ -27,6 +29,10 @@ Set the background color of the overlay window. Default is transparent. ## Window Settings +### Overlay Visibility + +The overlay starts closed on first launch. The app saves whether you show or hide it and restores that visibility on the next launch. + ### Always on Top Keeps the overlay window always above other windows. Useful when placing the key viewer over game screens. @@ -70,6 +76,9 @@ Select the reference point when resizing the overlay window: - **bottom-left**: Fixed bottom-left, expand top-right - **bottom-right**: Fixed bottom-right, expand top-left - **center**: Fixed center, expand all directions +- **fixed-position**: Preserve existing elements' screen positions when the window size or content origin changes + +The default, top-left, keeps the window's top-left corner fixed. If you move an element past the left or top edge of the content, or delete the leftmost or topmost element, the remaining elements shift on screen. Choose fixed-position to preserve their screen positions. Sprites extending past the left or top edge are compensated under every anchor, so they do not move existing elements. ## Custom CSS/JS diff --git a/docs/content/ko/api-reference/overlay/page.mdx b/docs/content/ko/api-reference/overlay/page.mdx index 6552be691..550037bf8 100644 --- a/docs/content/ko/api-reference/overlay/page.mdx +++ b/docs/content/ko/api-reference/overlay/page.mdx @@ -53,12 +53,13 @@ await dmn.overlay.setLock(false); // 잠금 해제 리사이즈 기준점을 설정합니다. - 지원 값: `top-left`, `top-right`, `bottom-left`, `bottom-right`, `center` + 지원 값: `top-left`, `top-right`, `bottom-left`, `bottom-right`, `center`, `fixed-position` ```javascript await dmn.overlay.setAnchor("top-left"); // 좌상단 기준 await dmn.overlay.setAnchor("center"); // 중앙 기준 +await dmn.overlay.setAnchor("fixed-position"); // 기존 요소의 화면 위치 유지 await dmn.overlay.setAnchor("bottom-right"); // 우하단 기준 ``` diff --git a/docs/content/ko/api-reference/settings/page.mdx b/docs/content/ko/api-reference/settings/page.mdx index dd16b97c1..99cc7134a 100644 --- a/docs/content/ko/api-reference/settings/page.mdx +++ b/docs/content/ko/api-reference/settings/page.mdx @@ -34,7 +34,8 @@ interface SettingsState { | "top-right" | "bottom-left" | "bottom-right" - | "center"; + | "center" + | "fixed-position"; keyCounterEnabled: boolean; gridSettings: { alignmentGuides: boolean; diff --git a/docs/content/ko/guide/settings/page.mdx b/docs/content/ko/guide/settings/page.mdx index 6bdc534fc..5a8093d79 100644 --- a/docs/content/ko/guide/settings/page.mdx +++ b/docs/content/ko/guide/settings/page.mdx @@ -20,12 +20,18 @@ DM Note의 다양한 설정 옵션을 안내합니다. 오버레이 창의 배경색을 설정합니다. 기본값은 투명입니다. +저장에 실패하면 화면 배경은 저장된 값으로 복원되지만 팝업의 입력값은 유지됩니다. 오류 원인을 해소한 뒤 **다시 저장**을 누르면 같은 초안을 재시도할 수 있습니다. 초안은 팝업을 닫았다 다시 열어도 유지되지만 앱을 종료하면 사라집니다. + OBS에서 윈도우 캡쳐를 사용하면 투명 배경이 유지됩니다. 크로마키가 필요 없습니다. ## 창 설정 +### 오버레이 표시 상태 + +처음 실행할 때 오버레이는 닫힌 상태입니다. 오버레이를 켜거나 끈 상태를 저장하며, 다음 실행 때 마지막 표시 상태를 복원합니다. + ### 항상 위에 표시 오버레이 창이 다른 창 위에 항상 표시되도록 합니다. 게임 화면 위에 키뷰어를 배치할 때 유용합니다. @@ -69,6 +75,9 @@ DM Note의 다양한 설정 옵션을 안내합니다. - **좌하단(bottom-left)**: 좌하단 고정, 우상단으로 확장 - **우하단(bottom-right)**: 우하단 고정, 좌상단으로 확장 - **중앙(center)**: 중앙 고정, 모든 방향으로 확장 +- **위치 고정(fixed-position)**: 창 크기와 콘텐츠 원점이 바뀌어도 기존 요소의 화면 위치 유지 + +기본값인 좌상단은 창의 좌상단 모서리를 고정합니다. 요소를 콘텐츠의 왼쪽이나 위쪽 끝 너머로 옮기거나 가장 왼쪽·위쪽 요소를 삭제하면 나머지 요소의 화면 위치가 함께 이동합니다. 기존 요소의 화면 위치를 유지하려면 위치 고정을 선택합니다. 스프라이트가 왼쪽이나 위쪽으로 넘치는 경우는 모든 기준점에서 보정되므로 기존 요소가 움직이지 않습니다. ## 커스텀 CSS/JS diff --git a/src/renderer/hooks/Modal/usePalette.test.tsx b/src/renderer/hooks/Modal/usePalette.test.tsx index 7b5450f22..108c9ec77 100644 --- a/src/renderer/hooks/Modal/usePalette.test.tsx +++ b/src/renderer/hooks/Modal/usePalette.test.tsx @@ -50,13 +50,39 @@ describe('배경색 저장 실패 복원', () => { vi.restoreAllMocks(); }); - it('거절된 낙관 배경색을 저장된 값으로 되돌리고 안내한다', async () => { + it('화면만 저장값으로 복원하고 팝업을 다시 열어도 초안과 오류를 유지한다', async () => { mocks.update.mockRejectedValue(new Error('disk full')); await act(async () => palette.handleColorChange('#222222')); expect(useSettingsStore.getState().backgroundColor).toBe('#111111'); - expect(palette.color).toBe('#111111'); - expect(mocks.alert).toHaveBeenCalledTimes(1); + expect(palette.backgroundColor).toBe('#111111'); + expect(palette.color).toBe('#222222'); + expect(palette.saveError).toBe('common.saveFailed'); + expect(palette.isSaving).toBe(false); + expect(mocks.alert).not.toHaveBeenCalled(); + act(() => palette.setPalette(true)); + act(() => palette.handlePaletteClose()); + act(() => palette.setPalette(true)); + expect(palette.color).toBe('#222222'); + expect(palette.saveError).toBe('common.saveFailed'); + }); + + it('쓰기 복구 후 같은 초안을 재시도하고 이후 외부 변경을 반영한다', async () => { + mocks.update + .mockRejectedValueOnce(new Error('disk full')) + .mockResolvedValue({}); + await act(async () => palette.handleColorChange('#222222')); + await act(async () => palette.handleColorChange(palette.color)); + + expect(mocks.update).toHaveBeenCalledTimes(2); + expect(mocks.update).toHaveBeenLastCalledWith({ + backgroundColor: '#222222', + }); + expect(useSettingsStore.getState().backgroundColor).toBe('#222222'); + expect(palette.saveError).toBeNull(); + expect(palette.isSaving).toBe(false); + act(() => useSettingsStore.getState().setBackgroundColor('#555555')); + expect(palette.color).toBe('#555555'); }); it('이전 저장 실패가 나중에 선택한 배경색을 덮지 않는다', async () => { @@ -75,6 +101,7 @@ describe('배경색 저장 실패 복원', () => { expect(useSettingsStore.getState().backgroundColor).toBe('#333333'); expect(palette.color).toBe('#333333'); + expect(palette.saveError).toBeNull(); }); it('저장값 재조회 중 도착한 외부 배경색을 덮지 않는다', async () => { @@ -90,5 +117,7 @@ describe('배경색 저장 실패 복원', () => { await act(async () => finishRead({ backgroundColor: '#111111' })); expect(useSettingsStore.getState().backgroundColor).toBe('#444444'); + expect(palette.backgroundColor).toBe('#444444'); + expect(palette.color).toBe('#222222'); }); }); diff --git a/src/renderer/hooks/Modal/usePalette.ts b/src/renderer/hooks/Modal/usePalette.ts index 69e917ec9..d09e44f65 100644 --- a/src/renderer/hooks/Modal/usePalette.ts +++ b/src/renderer/hooks/Modal/usePalette.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useRef, useCallback } from 'react'; import { useSettingsStore } from '@stores/useSettingsStore'; import { settingsApi } from '@api/modules/app/settingsApi'; import { useTranslation } from '@contexts/useTranslation'; @@ -6,40 +6,50 @@ import { useTranslation } from '@contexts/useTranslation'; export function usePalette() { const { t } = useTranslation(); const writeSequence = useRef(0); + const [saveError, setSaveError] = useState(null); + const [isSaving, setIsSaving] = useState(false); const [palette, setPalette] = useState(false); const backgroundColor = useSettingsStore((state) => state.backgroundColor); const setBackgroundColor = useSettingsStore( (state) => state.setBackgroundColor, ); - const [color, setColor] = useState(backgroundColor); + const [draftColor, setDraftColor] = useState(null); + const color = draftColor ?? backgroundColor; - useEffect(() => { - setColor(backgroundColor); - }, [backgroundColor]); - - const handleColorChange = (newColor: string) => { - const previousColor = useSettingsStore.getState().backgroundColor; - const sequence = ++writeSequence.current; - setColor(newColor); - setBackgroundColor(newColor); - settingsApi.update({ backgroundColor: newColor }).catch(async (error) => { - console.error('Failed to update background color', error); - if (sequence !== writeSequence.current) return; - let restoredColor = previousColor; - try { - restoredColor = (await settingsApi.get()).backgroundColor; - } catch (syncError) { - console.error('Failed to reload background color', syncError); - } - if (sequence !== writeSequence.current) return; - if (useSettingsStore.getState().backgroundColor === newColor) { - setBackgroundColor(restoredColor); - } - void window.api.ui.dialog - .alert(t('common.saveFailed'), { confirmText: t('common.ok') }) - .catch(() => {}); - }); - }; + const handleColorChange = useCallback( + (newColor: string) => { + const previousColor = useSettingsStore.getState().backgroundColor; + const sequence = ++writeSequence.current; + setIsSaving(true); + setDraftColor(newColor); + setBackgroundColor(newColor); + settingsApi + .update({ backgroundColor: newColor }) + .then(() => { + if (sequence !== writeSequence.current) return; + setSaveError(null); + setDraftColor(null); + setIsSaving(false); + }) + .catch(async (error) => { + console.error('Failed to update background color', error); + if (sequence !== writeSequence.current) return; + let restoredColor = previousColor; + try { + restoredColor = (await settingsApi.get()).backgroundColor; + } catch (syncError) { + console.error('Failed to reload background color', syncError); + } + if (sequence !== writeSequence.current) return; + if (useSettingsStore.getState().backgroundColor === newColor) { + setBackgroundColor(restoredColor); + } + setSaveError(t('common.saveFailed')); + setIsSaving(false); + }); + }, + [setBackgroundColor, t], + ); const handlePaletteClose = () => { if (palette) setPalette(false); @@ -50,7 +60,11 @@ export function usePalette() { }; return { + // 캔버스 배경은 적용값, 팝업 입력은 실패 초안 포함 + backgroundColor, color, + saveError, + isSaving, palette, setPalette, handleColorChange, diff --git a/src/renderer/windows/main/App.tsx b/src/renderer/windows/main/App.tsx index abeb9139a..6a455c527 100644 --- a/src/renderer/windows/main/App.tsx +++ b/src/renderer/windows/main/App.tsx @@ -172,8 +172,16 @@ export default function App() { handleUndo, handleRedo, } = useKeyManager(); - const { color, palette, setPalette, handleColorChange, handlePaletteClose } = - usePalette(); + const { + backgroundColor, + color, + palette, + setPalette, + handleColorChange, + handlePaletteClose, + saveError, + isSaving, + } = usePalette(); const [activeTool, setActiveTool] = useState('move'); const [toolbarAddRequest, setToolbarAddRequest] = useState<{ @@ -429,7 +437,7 @@ export default function App() { + {saveError && ( +
+

{saveError}

+ +
+ )} {noteSettingPresence.mounted && shownNoteSettings && (