From be5de9605adb0a07aa9ef4f15cd8a9180ac72b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=9C=ED=9B=88?= Date: Mon, 7 Sep 2026 15:31:50 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B0=B0=EA=B2=BD=EC=83=89=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EC=B4=88=EC=95=88?= =?UTF-8?q?=20=EC=9C=A0=EC=A7=80=EC=99=80=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 저장된 화면 상태와 편집 초안을 분리하고 팝업 안에서 오류와 재저장 동작을 제공한다. 초안 유지·재시도·저장 응답 순서에 대한 회귀 테스트를 보완한다. 기존 오버레이 표시·리사이즈 기준점 정책과 fixed-position API 값을 한글·영문 문서에 반영한다. --- .../content/en/api-reference/overlay/page.mdx | 5 +- .../en/api-reference/settings/page.mdx | 3 +- docs/content/en/guide/settings/page.mdx | 9 +++ .../content/ko/api-reference/overlay/page.mdx | 3 +- .../ko/api-reference/settings/page.mdx | 3 +- docs/content/ko/guide/settings/page.mdx | 9 +++ src/renderer/hooks/Modal/usePalette.test.tsx | 33 ++++++++- src/renderer/hooks/Modal/usePalette.ts | 70 +++++++++++-------- src/renderer/windows/main/App.tsx | 24 ++++++- 9 files changed, 120 insertions(+), 39 deletions(-) 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..152254a99 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, fixes the window corner. When a sprite extends beyond the left or top edge and changes the content origin, existing elements can move on screen. Choose fixed-position to preserve their screen positions. ## 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..dcb80ac4f 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..e9f2d8edc 100644 --- a/src/renderer/hooks/Modal/usePalette.test.tsx +++ b/src/renderer/hooks/Modal/usePalette.test.tsx @@ -50,13 +50,38 @@ 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.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 +100,7 @@ describe('배경색 저장 실패 복원', () => { expect(useSettingsStore.getState().backgroundColor).toBe('#333333'); expect(palette.color).toBe('#333333'); + expect(palette.saveError).toBeNull(); }); it('저장값 재조회 중 도착한 외부 배경색을 덮지 않는다', async () => { @@ -90,5 +116,6 @@ describe('배경색 저장 실패 복원', () => { await act(async () => finishRead({ backgroundColor: '#111111' })); expect(useSettingsStore.getState().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..26df8dbc1 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); @@ -51,6 +61,8 @@ export function usePalette() { return { color, + saveError, + isSaving, palette, setPalette, handleColorChange, diff --git a/src/renderer/windows/main/App.tsx b/src/renderer/windows/main/App.tsx index abeb9139a..9c09def02 100644 --- a/src/renderer/windows/main/App.tsx +++ b/src/renderer/windows/main/App.tsx @@ -172,8 +172,15 @@ export default function App() { handleUndo, handleRedo, } = useKeyManager(); - const { color, palette, setPalette, handleColorChange, handlePaletteClose } = - usePalette(); + const { + color, + palette, + setPalette, + handleColorChange, + handlePaletteClose, + saveError, + isSaving, + } = usePalette(); const [activeTool, setActiveTool] = useState('move'); const [toolbarAddRequest, setToolbarAddRequest] = useState<{ @@ -501,6 +508,19 @@ export default function App() { contentMountStrategy="after-paint" > + {saveError && ( +
+

{saveError}

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