mirror of
https://github.com/readest/readest
synced 2026-04-21 13:37:44 +00:00
refactor(settings): persist the apply-globally toggle per book (#3856)
This commit is contained in:
parent
41b5e92563
commit
96678d85ec
10 changed files with 217 additions and 39 deletions
184
apps/readest-app/docs/view-settings.md
Normal file
184
apps/readest-app/docs/view-settings.md
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
## Adding a Config to `ViewSettings`
|
||||
|
||||
`ViewSettings` is the per-book view state (layout, fonts, colors, TTS, etc.) composed from several sub-interfaces defined in `src/types/book.ts`. A matching `globalViewSettings` lives on `SystemSettings` and acts as the default for every book. The per-book value is derived by merging the global defaults with any overrides stored on the book's `BookConfig`.
|
||||
|
||||
This doc covers how to plumb a new config through the three layers:
|
||||
|
||||
1. **Types** — `src/types/book.ts`
|
||||
2. **Defaults** — `src/services/constants.ts` and `src/services/settingsService.ts`
|
||||
3. **Read/write** — components via `saveViewSettings` from `src/helpers/settings.ts`
|
||||
|
||||
### Pick a Pattern
|
||||
|
||||
**Pattern A — add a field to an existing sub-interface.** Use when the new option belongs to an existing bundle (`BookLayout`, `BookStyle`, `BookFont`, `ViewConfig`, `TTSConfig`, etc.).
|
||||
|
||||
**Pattern B — introduce a new sub-interface.** Use when several related fields cluster together, or when a single field is semantically its own concept (e.g. `ParagraphModeConfig`, `ViewSettingsConfig`). Then extend `ViewSettings` with it.
|
||||
|
||||
Both patterns follow the same three-layer flow. The only difference is whether you reuse an existing `DEFAULT_*` constant or add a new one.
|
||||
|
||||
### Step 1 — Declare the Type
|
||||
|
||||
**Pattern A** — add a required field to the sub-interface that owns this concern:
|
||||
|
||||
```ts
|
||||
// src/types/book.ts
|
||||
export interface ViewConfig {
|
||||
// ...existing fields
|
||||
myNewToggle: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern B** — define a new interface and extend `ViewSettings`:
|
||||
|
||||
```ts
|
||||
// src/types/book.ts
|
||||
export interface ViewSettingsConfig {
|
||||
isGlobal: boolean;
|
||||
}
|
||||
|
||||
export interface ViewSettings
|
||||
extends
|
||||
BookLayout,
|
||||
BookStyle,
|
||||
// ...other bundles
|
||||
ViewSettingsConfig {}
|
||||
```
|
||||
|
||||
Fields should be **required**, not optional. Optional fields make downstream code defensive. Provide a sensible default in Step 2 instead.
|
||||
|
||||
### Step 2 — Provide a Default
|
||||
|
||||
Every field in `ViewSettings` must have a default, otherwise `getDefaultViewSettings()` produces an incomplete object.
|
||||
|
||||
**Pattern A** — add the value to the existing `DEFAULT_*` constant:
|
||||
|
||||
```ts
|
||||
// src/services/constants.ts
|
||||
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
|
||||
// ...existing defaults
|
||||
myNewToggle: false,
|
||||
};
|
||||
```
|
||||
|
||||
**Pattern B** — add a `DEFAULT_*_CONFIG` constant for your new bundle, then register it in `getDefaultViewSettings`:
|
||||
|
||||
```ts
|
||||
// src/services/constants.ts
|
||||
export const DEFAULT_VIEW_SETTINGS_CONFIG: ViewSettingsConfig = {
|
||||
isGlobal: true,
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/services/settingsService.ts
|
||||
export function getDefaultViewSettings(ctx: Context): ViewSettings {
|
||||
return {
|
||||
...DEFAULT_BOOK_LAYOUT,
|
||||
...DEFAULT_BOOK_STYLE,
|
||||
// ...other bundles
|
||||
...DEFAULT_VIEW_SETTINGS_CONFIG,
|
||||
// platform overrides go last so they win
|
||||
...(ctx.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}),
|
||||
...(ctx.isEink ? DEFAULT_EINK_VIEW_SETTINGS : {}),
|
||||
...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### Platform Overrides
|
||||
|
||||
To tweak the default on mobile, e-ink, or CJK locales, add the field to the matching `Partial<ViewSettings>` constant (`DEFAULT_MOBILE_VIEW_SETTINGS`, `DEFAULT_EINK_VIEW_SETTINGS`, `DEFAULT_CJK_VIEW_SETTINGS`). These are spread after the base defaults in `getDefaultViewSettings`, so they override them.
|
||||
|
||||
#### Migration
|
||||
|
||||
Old `settings.json` files on disk won't have your new field. `loadSettings` merges the stored blob over fresh defaults:
|
||||
|
||||
```ts
|
||||
settings.globalViewSettings = {
|
||||
...getDefaultViewSettings(ctx),
|
||||
...settings.globalViewSettings,
|
||||
};
|
||||
```
|
||||
|
||||
So existing users pick up your default automatically — no explicit migration is needed for adding a field. Only bump `SYSTEM_SETTINGS_VERSION` if you are reshaping existing data.
|
||||
|
||||
### Step 3 — Read and Write from Components
|
||||
|
||||
Read the current value by preferring the per-book settings, falling back to the global:
|
||||
|
||||
```tsx
|
||||
const { settings } = useSettingsStore();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
|
||||
```
|
||||
|
||||
Write via `saveViewSettings` — never mutate the store directly. The helper handles the global-vs-per-book routing, persists to disk, and re-applies styles when needed.
|
||||
|
||||
```tsx
|
||||
import { saveViewSettings } from '@/helpers/settings';
|
||||
|
||||
const [myNewToggle, setMyNewToggle] = useState(viewSettings.myNewToggle);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'myNewToggle', myNewToggle);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [myNewToggle]);
|
||||
```
|
||||
|
||||
The `useEffect`-on-local-state pattern is the established convention in `LayoutPanel`, `ControlPanel`, `ColorPanel`, etc. It keeps the UI responsive and batches store updates until the user stops interacting.
|
||||
|
||||
#### Signature
|
||||
|
||||
```ts
|
||||
saveViewSettings<K extends keyof ViewSettings>(
|
||||
envConfig,
|
||||
bookKey,
|
||||
key: K,
|
||||
value: ViewSettings[K],
|
||||
skipGlobal = false, // true → only update this book's settings
|
||||
applyStyles = true, // false → don't re-run style recomputation
|
||||
)
|
||||
```
|
||||
|
||||
**Global vs. per-book routing.** `saveViewSettings` inspects `viewSettings.isGlobal` on the target book. When `true` (the default), it writes to `globalViewSettings`, loops through every open book, and saves to disk. When `false`, it writes only to the one book's config.
|
||||
|
||||
**Skip global.** Pass `skipGlobal=true` when the setting is meta — i.e. it describes the settings system itself, not book content. The canonical case is toggling `isGlobal` from `DialogMenu`: you want the scope flag to live on the specific book without propagating it to every other book.
|
||||
|
||||
```tsx
|
||||
saveViewSettings(envConfig, bookKey, 'isGlobal', !isSettingsGlobal, true, false);
|
||||
```
|
||||
|
||||
**Skip styles.** Pass `applyStyles=false` for options that don't affect CSS rendering (toggles, flags, metadata). This avoids an unnecessary `renderer.setStyles` call.
|
||||
|
||||
### Step 4 — Support Reset
|
||||
|
||||
If your field should be resettable from the panel menu, register a setter in the panel's `handleReset` via `useResetViewSettings`:
|
||||
|
||||
```tsx
|
||||
const resetToDefaults = useResetViewSettings();
|
||||
|
||||
const handleReset = () => {
|
||||
resetToDefaults({
|
||||
myNewToggle: setMyNewToggle,
|
||||
// ...other setters
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
The hook resolves the default by reading from `getDefaultViewSettings(ctx)` and calls each provided setter with that value, which then fires your `useEffect` and persists the change.
|
||||
|
||||
### Don'ts
|
||||
|
||||
- **Don't make the field optional** just to skip providing a default. Add a default in Step 2 instead.
|
||||
- **Don't mutate `settings.globalViewSettings` directly** in a component — `saveViewSettings` already handles global propagation when `isGlobal` is true.
|
||||
- **Don't bump `SYSTEM_SETTINGS_VERSION`** for a plain additive field. The load-time merge handles it.
|
||||
|
||||
### Minimal Checklist
|
||||
|
||||
- [ ] Field or new interface added in `src/types/book.ts`
|
||||
- [ ] Default value in `src/services/constants.ts`
|
||||
- [ ] New `DEFAULT_*_CONFIG` spread into `getDefaultViewSettings` (Pattern B only)
|
||||
- [ ] Optional mobile/eink/CJK override in the matching `Partial<ViewSettings>` constant
|
||||
- [ ] Read via `getViewSettings(bookKey) || settings.globalViewSettings`
|
||||
- [ ] Write via `saveViewSettings(envConfig, bookKey, 'key', value)`
|
||||
- [ ] Reset setter wired into `useResetViewSettings` if the panel has a reset menu
|
||||
|
|
@ -66,7 +66,6 @@ describe('settingsStore', () => {
|
|||
settings: {} as SystemSettings,
|
||||
settingsDialogBookKey: '',
|
||||
isSettingsDialogOpen: false,
|
||||
isSettingsGlobal: true,
|
||||
fontPanelView: 'main-fonts',
|
||||
activeSettingsItemId: null,
|
||||
});
|
||||
|
|
@ -118,19 +117,6 @@ describe('settingsStore', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('setSettingsGlobal', () => {
|
||||
test('sets global mode to true', () => {
|
||||
useSettingsStore.getState().setSettingsGlobal(false);
|
||||
useSettingsStore.getState().setSettingsGlobal(true);
|
||||
expect(useSettingsStore.getState().isSettingsGlobal).toBe(true);
|
||||
});
|
||||
|
||||
test('sets global mode to false', () => {
|
||||
useSettingsStore.getState().setSettingsGlobal(false);
|
||||
expect(useSettingsStore.getState().isSettingsGlobal).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setFontPanelView', () => {
|
||||
test('sets to main-fonts', () => {
|
||||
useSettingsStore.getState().setFontPanelView('main-fonts');
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import { MdCheck } from 'react-icons/md';
|
|||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useCustomFontStore } from '@/store/customFontStore';
|
||||
import { saveViewSettings } from '@/helpers/settings';
|
||||
import { SettingsPanelType } from './SettingsDialog';
|
||||
import Menu from '@/components/Menu';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
|
@ -26,11 +28,14 @@ const DialogMenu: React.FC<DialogMenuProps> = ({
|
|||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { setFontPanelView, isSettingsGlobal, setSettingsGlobal } = useSettingsStore();
|
||||
const { setFontPanelView } = useSettingsStore();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const { getAllFonts, removeFont, saveCustomFonts } = useCustomFontStore();
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const isSettingsGlobal = viewSettings?.isGlobal ?? true;
|
||||
|
||||
const handleToggleGlobal = () => {
|
||||
setSettingsGlobal(!isSettingsGlobal);
|
||||
saveViewSettings(envConfig, bookKey, 'isGlobal', !isSettingsGlobal, true, false);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ type CSSType = 'book' | 'reader';
|
|||
const MiscPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService, envConfig } = useEnv();
|
||||
const { settings, isSettingsGlobal, setSettings } = useSettingsStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getView, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
|
||||
|
||||
|
|
@ -90,20 +90,10 @@ const MiscPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
|||
setDraftContentStylesheet(formattedCSS);
|
||||
setDraftContentStylesheetSaved(true);
|
||||
viewSettings.userStylesheet = formattedCSS;
|
||||
|
||||
if (isSettingsGlobal) {
|
||||
settings.globalViewSettings.userStylesheet = formattedCSS;
|
||||
setSettings(settings);
|
||||
}
|
||||
} else {
|
||||
setDraftUIStylesheet(formattedCSS);
|
||||
setDraftUIStylesheetSaved(true);
|
||||
viewSettings.userUIStylesheet = formattedCSS;
|
||||
|
||||
if (isSettingsGlobal) {
|
||||
settings.globalViewSettings.userUIStylesheet = formattedCSS;
|
||||
setSettings(settings);
|
||||
}
|
||||
}
|
||||
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const saveViewSettings = async <K extends keyof ViewSettings>(
|
|||
skipGlobal = false,
|
||||
applyStyles = true,
|
||||
) => {
|
||||
const { settings, isSettingsGlobal, setSettings, saveSettings } = useSettingsStore.getState();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore.getState();
|
||||
const { bookKeys, getView, getViewState, getViewSettings, setViewSettings } =
|
||||
useReaderStore.getState();
|
||||
const { getConfig, saveConfig } = useBookDataStore.getState();
|
||||
|
|
@ -36,6 +36,7 @@ export const saveViewSettings = async <K extends keyof ViewSettings>(
|
|||
}
|
||||
};
|
||||
|
||||
const isSettingsGlobal = getViewSettings(bookKey)?.isGlobal ?? true;
|
||||
if (isSettingsGlobal && !skipGlobal) {
|
||||
settings.globalViewSettings[key] = value;
|
||||
setSettings(settings);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
TTSConfig,
|
||||
ViewConfig,
|
||||
ViewSettings,
|
||||
ViewSettingsConfig,
|
||||
} from '@/types/book';
|
||||
import {
|
||||
HardcoverSettings,
|
||||
|
|
@ -265,6 +266,10 @@ export const DEFAULT_EINK_VIEW_SETTINGS: Partial<ViewSettings> = {
|
|||
volumeKeysToFlip: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_PARAGRAPH_MODE_CONFIG: ParagraphModeConfig = {
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
|
||||
sideBarTab: 'toc',
|
||||
uiLanguage: '',
|
||||
|
|
@ -293,6 +298,8 @@ export const DEFAULT_VIEW_CONFIG: ViewConfig = {
|
|||
isEink: false,
|
||||
isColorEink: false,
|
||||
|
||||
paragraphMode: DEFAULT_PARAGRAPH_MODE_CONFIG,
|
||||
|
||||
readingRulerEnabled: false,
|
||||
readingRulerLines: 2,
|
||||
readingRulerPosition: 33,
|
||||
|
|
@ -344,10 +351,6 @@ export const DEFAULT_SCREEN_CONFIG: ScreenConfig = {
|
|||
screenOrientation: 'auto',
|
||||
};
|
||||
|
||||
export const DEFAULT_PARAGRAPH_MODE_CONFIG: ParagraphModeConfig = {
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_BOOK_SEARCH_CONFIG: BookSearchConfig = {
|
||||
scope: 'book',
|
||||
matchCase: false,
|
||||
|
|
@ -355,6 +358,10 @@ export const DEFAULT_BOOK_SEARCH_CONFIG: BookSearchConfig = {
|
|||
matchDiacritics: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_VIEW_SETTINGS_CONFIG: ViewSettingsConfig = {
|
||||
isGlobal: true,
|
||||
};
|
||||
|
||||
export const SYSTEM_SETTINGS_VERSION = 1;
|
||||
|
||||
export const SERIF_FONTS = [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
DEFAULT_MOBILE_SYSTEM_SETTINGS,
|
||||
DEFAULT_ANNOTATOR_CONFIG,
|
||||
DEFAULT_EINK_VIEW_SETTINGS,
|
||||
DEFAULT_VIEW_SETTINGS_CONFIG,
|
||||
} from './constants';
|
||||
import { DEFAULT_AI_SETTINGS } from './ai/constants';
|
||||
import { getTargetLang, isCJKEnv } from '@/utils/misc';
|
||||
|
|
@ -43,6 +44,7 @@ export function getDefaultViewSettings(ctx: Context): ViewSettings {
|
|||
...DEFAULT_TTS_CONFIG,
|
||||
...DEFAULT_SCREEN_CONFIG,
|
||||
...DEFAULT_ANNOTATOR_CONFIG,
|
||||
...DEFAULT_VIEW_SETTINGS_CONFIG,
|
||||
...(ctx.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}),
|
||||
...(ctx.isEink ? DEFAULT_EINK_VIEW_SETTINGS : {}),
|
||||
...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}),
|
||||
|
|
|
|||
|
|
@ -10,14 +10,12 @@ interface SettingsState {
|
|||
settings: SystemSettings;
|
||||
settingsDialogBookKey: string;
|
||||
isSettingsDialogOpen: boolean;
|
||||
isSettingsGlobal: boolean;
|
||||
fontPanelView: FontPanelView;
|
||||
activeSettingsItemId: string | null;
|
||||
setSettings: (settings: SystemSettings) => void;
|
||||
saveSettings: (envConfig: EnvConfigType, settings: SystemSettings) => void;
|
||||
setSettingsDialogBookKey: (bookKey: string) => void;
|
||||
setSettingsDialogOpen: (open: boolean) => void;
|
||||
setSettingsGlobal: (global: boolean) => void;
|
||||
setFontPanelView: (view: FontPanelView) => void;
|
||||
setActiveSettingsItemId: (id: string | null) => void;
|
||||
|
||||
|
|
@ -28,7 +26,6 @@ export const useSettingsStore = create<SettingsState>((set) => ({
|
|||
settings: {} as SystemSettings,
|
||||
settingsDialogBookKey: '',
|
||||
isSettingsDialogOpen: false,
|
||||
isSettingsGlobal: true,
|
||||
fontPanelView: 'main-fonts',
|
||||
activeSettingsItemId: null,
|
||||
setSettings: (settings) => set({ settings }),
|
||||
|
|
@ -38,7 +35,6 @@ export const useSettingsStore = create<SettingsState>((set) => ({
|
|||
},
|
||||
setSettingsDialogBookKey: (bookKey) => set({ settingsDialogBookKey: bookKey }),
|
||||
setSettingsDialogOpen: (open) => set({ isSettingsDialogOpen: open }),
|
||||
setSettingsGlobal: (global) => set({ isSettingsGlobal: global }),
|
||||
setFontPanelView: (view) => set({ fontPanelView: view }),
|
||||
setActiveSettingsItemId: (id) => set({ activeSettingsItemId: id }),
|
||||
|
||||
|
|
|
|||
|
|
@ -261,6 +261,8 @@ export interface ViewConfig {
|
|||
isEink: boolean;
|
||||
isColorEink: boolean;
|
||||
|
||||
paragraphMode: ParagraphModeConfig;
|
||||
|
||||
readingRulerEnabled: boolean;
|
||||
readingRulerLines: number;
|
||||
readingRulerPosition: number;
|
||||
|
|
@ -333,6 +335,10 @@ export interface ProofreadRulesConfig {
|
|||
proofreadRules?: ProofreadRule[];
|
||||
}
|
||||
|
||||
export interface ViewSettingsConfig {
|
||||
isGlobal: boolean;
|
||||
}
|
||||
|
||||
export interface ViewSettings
|
||||
extends
|
||||
BookLayout,
|
||||
|
|
@ -344,9 +350,8 @@ export interface ViewSettings
|
|||
TranslatorConfig,
|
||||
ScreenConfig,
|
||||
ProofreadRulesConfig,
|
||||
AnnotatorConfig {
|
||||
paragraphMode?: ParagraphModeConfig;
|
||||
}
|
||||
AnnotatorConfig,
|
||||
ViewSettingsConfig {}
|
||||
|
||||
export interface BookProgress {
|
||||
location: string;
|
||||
|
|
|
|||
|
|
@ -125,6 +125,8 @@ export interface SystemSettings {
|
|||
migrationVersion: number;
|
||||
|
||||
aiSettings: AISettings;
|
||||
// Global read settings that apply to the reader page
|
||||
globalReadSettings: ReadSettings;
|
||||
// Global view settings that apply to all books, and can be overridden by book-specific view settings
|
||||
globalViewSettings: ViewSettings;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue