diff --git a/apps/readest-app/docs/view-settings.md b/apps/readest-app/docs/view-settings.md new file mode 100644 index 00000000..e1cbe24d --- /dev/null +++ b/apps/readest-app/docs/view-settings.md @@ -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` 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( + 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` 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 diff --git a/apps/readest-app/src/__tests__/store/settings-store.test.ts b/apps/readest-app/src/__tests__/store/settings-store.test.ts index ac627197..16a5bec3 100644 --- a/apps/readest-app/src/__tests__/store/settings-store.test.ts +++ b/apps/readest-app/src/__tests__/store/settings-store.test.ts @@ -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'); diff --git a/apps/readest-app/src/components/settings/DialogMenu.tsx b/apps/readest-app/src/components/settings/DialogMenu.tsx index f5a8d2e1..d40223fd 100644 --- a/apps/readest-app/src/components/settings/DialogMenu.tsx +++ b/apps/readest-app/src/components/settings/DialogMenu.tsx @@ -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 = ({ }) => { 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); }; diff --git a/apps/readest-app/src/components/settings/MiscPanel.tsx b/apps/readest-app/src/components/settings/MiscPanel.tsx index 7f1adf27..eb7a4445 100644 --- a/apps/readest-app/src/components/settings/MiscPanel.tsx +++ b/apps/readest-app/src/components/settings/MiscPanel.tsx @@ -16,7 +16,7 @@ type CSSType = 'book' | 'reader'; const MiscPanel: React.FC = ({ 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 = ({ 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 }); diff --git a/apps/readest-app/src/helpers/settings.ts b/apps/readest-app/src/helpers/settings.ts index 96772e6c..f774d21c 100644 --- a/apps/readest-app/src/helpers/settings.ts +++ b/apps/readest-app/src/helpers/settings.ts @@ -14,7 +14,7 @@ export const saveViewSettings = async ( 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 ( } }; + const isSettingsGlobal = getViewSettings(bookKey)?.isGlobal ?? true; if (isSettingsGlobal && !skipGlobal) { settings.globalViewSettings[key] = value; setSettings(settings); diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index e8c8d543..83245af1 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -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 = { 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 = [ diff --git a/apps/readest-app/src/services/settingsService.ts b/apps/readest-app/src/services/settingsService.ts index b780cb0f..c3880513 100644 --- a/apps/readest-app/src/services/settingsService.ts +++ b/apps/readest-app/src/services/settingsService.ts @@ -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 : {}), diff --git a/apps/readest-app/src/store/settingsStore.ts b/apps/readest-app/src/store/settingsStore.ts index e5ba274e..b9211042 100644 --- a/apps/readest-app/src/store/settingsStore.ts +++ b/apps/readest-app/src/store/settingsStore.ts @@ -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((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((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 }), diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index ba392d6e..589e8664 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -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; diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index 7c0ce7b3..b649a332 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -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; }