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 && (