8.4 KiB
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:
- Types —
src/types/book.ts - Defaults —
src/services/constants.tsandsrc/services/settingsService.ts - Read/write — components via
saveViewSettingsfromsrc/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:
// 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.
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:
// 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 : {}),
};
}
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:
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:
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.
Signature
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.
Step 4 — Support Reset
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.
Step 5 — Register in the Command Palette
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',
},
];
idmust match thedata-setting-idattribute on the panel row. The palette uses it to scroll/highlight the target control.labelKeyusesstubTranslation(imported as_) so the extractor picks it up — the same string that appears in the panel.keywordsbroadens fuzzy-search hits beyond the label; include synonyms, related jargon, and the panel section name.sectiongroups 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).
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.globalViewSettingsdirectly in a component —saveViewSettingsalready handles global propagation whenisGlobalis true. - Don't bump
SYSTEM_SETTINGS_VERSIONfor 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_*_CONFIGspread intogetDefaultViewSettings(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
useResetViewSettingsif the panel has a reset menu - Command-palette entry added to the matching
*PanelItemsarray insrc/services/commandRegistry.ts, with anidthat matches the panel row'sdata-setting-id