apps/readest-app/docs/view-settings.md
ViewSettingsViewSettings 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:
src/types/book.tssrc/services/constants.ts and src/services/settingsService.tssaveViewSettings from src/helpers/settings.tsPattern 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.
Pattern A — add a required field to the sub-interface that owns this concern:
// src/types/book.ts
export interface ViewConfig {
// ...existing fields
myNewToggle: boolean;
}
Pattern B — define a new interface and extend ViewSettings:
// 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.
Every field in ViewSettings must have a default, otherwise getDefaultViewSettings() produces an incomplete object.
Pattern A — add the value to the existing DEFAULT_* constant:
// 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:
// src/services/constants.ts
export const DEFAULT_VIEW_SETTINGS_CONFIG: ViewSettingsConfig = {
isGlobal: true,
};
// 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 : {}),
};
}
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.
Old settings.json files on disk won't have your new field. loadSettings merges the stored blob over fresh defaults:
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.
Read the current value by preferring the per-book settings, falling back to the global:
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.
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.
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.
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.
If your field should be resettable from the panel menu, register a setter in the panel's handleReset via useResetViewSettings:
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.
If your setting has a visible row in a panel, register it in the matching *PanelItems array in src/services/commandRegistry.ts. This wires it into the command-palette fuzzy search so users can jump straight to it.
// src/services/commandRegistry.ts
const layoutPanelItems = [
// ...existing entries
{
id: 'settings.layout.myNewToggle',
labelKey: _('My New Toggle'),
keywords: ['search', 'terms', 'for', 'discoverability'],
section: 'Paragraph',
},
];
id must match the data-setting-id attribute on the panel row. The palette uses it to scroll/highlight the target control.labelKey uses stubTranslation (imported as _) so the extractor picks it up — the same string that appears in the panel.keywords broadens fuzzy-search hits beyond the label; include synonyms, related jargon, and the panel section name.section groups the entry in the palette results (matches the panel's sub-header: Layout, Paragraph, Page, Header & Footer, etc.).Skip this step only for settings that don't surface as a user-visible row (hidden toggles, flags used by other settings).
settings.globalViewSettings directly in a component — saveViewSettings already handles global propagation when isGlobal is true.SYSTEM_SETTINGS_VERSION for a plain additive field. The load-time merge handles it.src/types/book.tssrc/services/constants.tsDEFAULT_*_CONFIG spread into getDefaultViewSettings (Pattern B only)Partial<ViewSettings> constantgetViewSettings(bookKey) || settings.globalViewSettingssaveViewSettings(envConfig, bookKey, 'key', value)useResetViewSettings if the panel has a reset menu*PanelItems array in src/services/commandRegistry.ts, with an id that matches the panel row's data-setting-id