Files
readest/apps/readest-app/src/services/commandRegistry.ts
T
Huang Xin 772bb73b46 ui/ux: codify design system and migrate settings to shared primitives (#4116)
* ui/ux: codify design system and migrate settings to shared primitives

Document Readest's design language in DESIGN.md (Adwaita-aligned, e-ink-first,
RTL-correct) and migrate every settings panel onto a small set of primitives
(BoxedList, SettingsRow, SettingsSwitchRow, SettingsSelect, SettingsInput,
NavigationRow, Tips, SubPageHeader). AGENTS.md links to DESIGN.md so contributors
land there before inventing new chassis classes.

Replace the standalone KOReader/Readwise/Hardcover Config dialogs with a single
Integrations panel (Reading Sync + Content Sources sub-pages). The reader's
BookMenu now hides each provider until it's configured, and Hardcover's per-book
"Enable for This Book" toggle is dropped — there's no auto-sync to gate, so the
flag was just extra clicks.

Refresh highlight colors (two-trigger swatch + label, translatable default
names), background texture / theme color selectors (border-current keeps
selection legible on any backdrop), CustomFonts/CustomDictionaries (quiet
list-extension style + shared Tips primitive), the OPDS catalog manager
(debounced auto-download, right-aligned Browse), Set PIN, and the KOSync
conflict resolver. Translate the ~30 new strings across all 33 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui/ux: responsive typography, OPDS card polish, deep-link return paths

Restore the .settings-content responsive cascade (14px desktop / 16px
mobile) the legacy panels relied on by dropping hardcoded `text-sm`/`text-xs`
from the new primitives. Secondary text moves to em-relative `text-[0.85em]`
so it scales with the parent. Form controls (`<input>`, `<select>`) re-apply
the cascade explicitly via the `settings-content` class since browsers don't
inherit font-size onto form elements.

Extract `<SectionTitle>` primitive (caseless-language aware via
`isCaselessUILang`/`isCaselessLang`) and route every uppercase tag-style
header through it: BoxedList groups, Reading Sync, Content Sources, Theme
Color, Background Image, integration form labels, KOSyncResolver device
labels, and the OPDS My Catalogs / Popular Catalogs sections. CJK / Arabic
/ Hebrew / Indic / Thai / Tibetan locales bump to `1em` since `uppercase`
is a no-op on those scripts.

Redesign the OPDS My Catalogs cards: whole card becomes the browse trigger
(role='button'), edit/delete collapse into a 3-dot dropdown menu, and the
sync-status moves to a sub-line under Auto-download so the card height stays
constant whether the toggle is on/off or sync data has arrived.

Plumb a `from=settings-integrations` URL marker through the OPDS browser so
both manual close and auto-close-on-failure (preserved as `router.back()`
for transient failures, paired with a new `stashOPDSReturnTarget` helper)
return the user to Settings -> Integrations -> OPDS Catalogs sub-page
rather than the dialog's top level. Backed by new `requestedSubPage`
deep-link store field.

Skip the OPDS catalog passphrase prompt when credentials sync is disabled
-- `replicaPublish` already drops encrypted fields at the wire, so prompting
was both pointless and confusing.

Fix `SettingsDialog` calling `setRequestedPanel(null)` inside a `useState`
lazy initializer (zustand setter during render -> React warning); move the
clear into a one-shot `useEffect`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui/ux: opt Settings into OverlayScrollbars + caseless typography polish

Add an opt-in `useOverlayScroll` prop to `<Dialog>` that swaps the body's
native `overflow-y-auto` for `<OverlayScrollbarsComponent>` (autohide,
click-scroll, no native overlaid bars). SettingsDialog flips it on so the
long Layout / Color panels keep a visible, theme-aware scroll track on
Android / iOS webviews where native scrollbars auto-hide entirely. Other
short-modal callers stay on the native scrollbar.

Drop the `uppercase tracking-wider` SectionTitle styling for caseless
scripts and pair it with body-weight `font-medium` instead — those
typographic effects are no-ops on Han / Hangul / Devanagari / Thai etc.,
so a plain medium-weight body-size title reads more correctly than a
shrunken pseudo-uppercase one. SettingsRow / NavigationRow primary labels
follow the same rule (drop `font-medium` in caseless locales since the
inherited body weight already carries; CJK fonts bold poorly at body
size).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui/ux: SettingLabel primitive + KOSyncForm select polish + Tips alignment

Add `<SettingLabel>` primitive — caseless-aware row/field label that pairs
with `<SectionTitle>` (groups) for per-item labels. Cased scripts get
`font-medium`; caseless scripts (CJK / Arabic / Hebrew / Indic / Thai /
Tibetan) drop the weight since Han / Hangul / Devanagari etc. bold poorly
at body size. No font-size class so it inherits the `.settings-content`
14/16 cascade. Routed through `SettingsRow`, `NavigationRow`, and the
~12 ad-hoc inline `text-sm font-medium` callsites in AIPanel / FontPanel
/ ColorPanel / IntegrationsPanel / KOSync / Readwise / Hardcover forms.

Refactor KOSyncForm's Sync Strategy + Checksum Method rows onto the
shared `<SettingsSelect>` primitive — the inline 17-line div/select/
MdArrowDropDown chassis becomes a single SettingsSelect call with an
options array. Drops the unused MdArrowDropDown import and ~25 lines.

Fix Tips list-item alignment: callers traditionally pass `<li>` elements
(semantic) but the primitive was double-wrapping into `<li><span><li>...</li></span></li>` — invalid HTML, and the inner `<li>`'s
`display: list-item` broke line-wrap alignment on multi-line items.
Unwrap caller `<li>` to its content; add `flex-1` on the text span so
wrapped lines align under the first line instead of falling back to the
bullet column. Bullet container switches to `h-[1.4em]` so it tracks the
text line-height and pins to the first line's optical center via
`items-center` regardless of how much the content wraps.

DESIGN.md §5 typography updated to point primary-label callers at
`<SettingLabel>`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:46:25 +02:00

864 lines
23 KiB
TypeScript

import { Fzf, FzfResultItem, byLengthAsc } from 'fzf';
import { SettingsPanelType } from '@/components/settings/SettingsDialog';
import { RiFontSize, RiDashboardLine, RiTranslate, RiShareLine } from 'react-icons/ri';
import { VscSymbolColor } from 'react-icons/vsc';
import { LiaHandPointerSolid } from 'react-icons/lia';
import { IoAccessibilityOutline } from 'react-icons/io5';
import { PiRobot, PiSpeakerHigh, PiSun, PiMoon } from 'react-icons/pi';
import { TbSunMoon } from 'react-icons/tb';
import { MdRefresh } from 'react-icons/md';
import { IconType } from 'react-icons';
import { stubTranslation as _ } from '@/utils/misc';
export type CommandCategory = 'settings' | 'actions' | 'navigation';
export interface CommandItem {
id: string;
labelKey: string;
localizedLabel: string;
keywords: string[];
category: CommandCategory;
panel?: SettingsPanelType;
panelLabel?: string;
section?: string;
icon?: IconType;
shortcut?: string[];
action: () => void;
isAvailable?: () => boolean;
}
export interface CommandSearchResult {
item: CommandItem;
score: number;
positions: Set<number>;
highlightIndices: Set<number>;
matchContext?: string;
}
type TranslationFunc = (key: string) => string;
// selector for fzf - combines all searchable text
const getSearchableText = (item: CommandItem): string => {
return [
item.localizedLabel,
item.labelKey,
item.panel ?? '',
item.panelLabel ?? '',
item.section ?? '',
...item.keywords,
]
.filter(Boolean)
.join(' ');
};
// map fzf positions to display label positions
const mapPositionsToLabel = (entry: FzfResultItem<CommandItem>): Set<number> => {
const searchText = getSearchableText(entry.item);
const label = entry.item.localizedLabel;
const labelStart = searchText.indexOf(label);
if (labelStart === -1) return new Set();
const labelEnd = labelStart + label.length;
const mapped = new Set<number>();
for (const pos of entry.positions) {
if (pos >= labelStart && pos < labelEnd) {
mapped.add(pos - labelStart);
}
}
return mapped;
};
// find matched context from keywords/section/panel for secondary display
const findMatchContext = (entry: FzfResultItem<CommandItem>): string | undefined => {
const searchText = getSearchableText(entry.item);
const label = entry.item.localizedLabel;
const labelStart = searchText.indexOf(label);
const labelEnd = labelStart + label.length;
// check if any match is outside the label
for (const pos of entry.positions) {
if (pos < labelStart || pos >= labelEnd) {
// match is in keywords/section/panel area
const parts = [
entry.item.panelLabel ?? entry.item.panel,
entry.item.section,
...entry.item.keywords,
].filter(Boolean);
for (const part of parts) {
if (part && searchText.includes(part)) {
const partStart = searchText.indexOf(part, labelEnd);
if (partStart !== -1) {
for (const p of entry.positions) {
if (p >= partStart && p < partStart + part.length) {
return part;
}
}
}
}
}
}
}
return undefined;
};
export const searchCommands = (query: string, items: CommandItem[]): CommandSearchResult[] => {
if (!query.trim()) return [];
const availableItems = items.filter((item) => !item.isAvailable || item.isAvailable());
const fzf = new Fzf(availableItems, {
selector: getSearchableText,
tiebreakers: [byLengthAsc],
casing: 'smart-case',
normalize: true,
limit: 50,
});
const results = fzf.find(query);
return results.map((entry) => ({
item: entry.item,
score: entry.score,
positions: entry.positions,
highlightIndices: mapPositionsToLabel(entry),
matchContext: findMatchContext(entry),
}));
};
// group results by category
export const groupResultsByCategory = (
results: CommandSearchResult[],
): Record<CommandCategory, CommandSearchResult[]> => {
const grouped: Record<CommandCategory, CommandSearchResult[]> = {
settings: [],
actions: [],
navigation: [],
};
for (const result of results) {
grouped[result.item.category].push(result);
}
return grouped;
};
// settings panel icon map
const panelIcons: Record<SettingsPanelType, IconType> = {
Font: RiFontSize,
Layout: RiDashboardLine,
Color: VscSymbolColor,
Control: LiaHandPointerSolid,
TTS: PiSpeakerHigh,
Language: RiTranslate,
AI: PiRobot,
Integrations: RiShareLine,
Custom: IoAccessibilityOutline,
};
// font panel items
const fontPanelItems = [
{
id: 'settings.font.overrideBookFont',
labelKey: _('Override Book Font'),
keywords: ['font', 'override', 'book', 'custom'],
section: 'Font',
},
{
id: 'settings.font.defaultFontSize',
labelKey: _('Default Font Size'),
keywords: ['font', 'size', 'default', 'px', 'pixels', 'text'],
section: 'Font Size',
},
{
id: 'settings.font.minimumFontSize',
labelKey: _('Minimum Font Size'),
keywords: ['font', 'size', 'minimum', 'min', 'small'],
section: 'Font Size',
},
{
id: 'settings.font.fontWeight',
labelKey: _('Font Weight'),
keywords: ['font', 'weight', 'bold', 'light', 'thickness'],
section: 'Font Weight',
},
{
id: 'settings.font.defaultFont',
labelKey: _('Default Font'),
keywords: ['font', 'family', 'serif', 'sans', 'default'],
section: 'Font Family',
},
{
id: 'settings.font.cjkFont',
labelKey: _('CJK Font'),
keywords: ['font', 'cjk', 'chinese', 'japanese', 'korean', 'asian'],
section: 'Font Family',
},
{
id: 'settings.font.serifFont',
labelKey: _('Serif Font'),
keywords: ['font', 'serif', 'family', 'typeface'],
section: 'Font Face',
},
{
id: 'settings.font.sansSerifFont',
labelKey: _('Sans-Serif Font'),
keywords: ['font', 'sans', 'serif', 'family', 'typeface'],
section: 'Font Face',
},
{
id: 'settings.font.monospaceFont',
labelKey: _('Monospace Font'),
keywords: ['font', 'monospace', 'mono', 'code', 'fixed', 'width'],
section: 'Font Face',
},
];
// layout panel items
const layoutPanelItems = [
{
id: 'settings.layout.overrideBookLayout',
labelKey: _('Override Book Layout'),
keywords: ['layout', 'override', 'book', 'custom'],
section: 'Layout',
},
{
id: 'settings.layout.writingMode',
labelKey: _('Writing Mode'),
keywords: ['writing', 'mode', 'vertical', 'horizontal', 'direction', 'rtl', 'ltr'],
section: 'Layout',
},
{
id: 'settings.layout.borderFrame',
labelKey: _('Border Frame'),
keywords: ['border', 'frame', 'vertical', 'mode'],
section: 'Layout',
},
{
id: 'settings.layout.useBookLayout',
labelKey: _('Use Book Layout'),
keywords: ['paragraph', 'book', 'layout', 'default', 'original', 'preserve'],
section: 'Paragraph',
},
{
id: 'settings.layout.paragraphMargin',
labelKey: _('Paragraph Margin'),
keywords: ['paragraph', 'margin', 'spacing', 'gap'],
section: 'Paragraph',
},
{
id: 'settings.layout.lineSpacing',
labelKey: _('Line Spacing'),
keywords: ['line', 'spacing', 'height', 'leading'],
section: 'Paragraph',
},
{
id: 'settings.layout.wordSpacing',
labelKey: _('Word Spacing'),
keywords: ['word', 'spacing', 'gap'],
section: 'Paragraph',
},
{
id: 'settings.layout.letterSpacing',
labelKey: _('Letter Spacing'),
keywords: ['letter', 'spacing', 'tracking', 'character'],
section: 'Paragraph',
},
{
id: 'settings.layout.paragraphIndent',
labelKey: _('Text Indent'),
keywords: ['paragraph', 'indent', 'first', 'line'],
section: 'Paragraph',
},
{
id: 'settings.layout.fullJustification',
labelKey: _('Full Justification'),
keywords: ['justify', 'justification', 'alignment', 'text', 'full'],
section: 'Paragraph',
},
{
id: 'settings.layout.hyphenation',
labelKey: _('Hyphenation'),
keywords: ['hyphen', 'hyphenation', 'break', 'word'],
section: 'Paragraph',
},
{
id: 'settings.layout.pageMargins',
labelKey: _('Page Margins'),
keywords: ['page', 'margin', 'edge', 'border'],
section: 'Page',
},
{
id: 'settings.layout.pageGap',
labelKey: _('Column Gap (%)'),
keywords: ['page', 'gap', 'spacing', 'gutter'],
section: 'Page',
},
{
id: 'settings.layout.maxColumnCount',
labelKey: _('Maximum Number of Columns'),
keywords: ['column', 'columns', 'max', 'count', 'multi'],
section: 'Page',
},
{
id: 'settings.layout.maxInlineSize',
labelKey: _('Maximum Column Width'),
keywords: ['width', 'max', 'inline', 'size', 'column'],
section: 'Page',
},
{
id: 'settings.layout.maxBlockSize',
labelKey: _('Maximum Column Height'),
keywords: ['height', 'max', 'block', 'size'],
section: 'Page',
},
{
id: 'settings.layout.showHeader',
labelKey: _('Show Header'),
keywords: ['header', 'show', 'top', 'bar', 'title'],
section: 'Header & Footer',
},
{
id: 'settings.layout.showFooter',
labelKey: _('Show Footer'),
keywords: ['footer', 'show', 'bottom', 'bar', 'page', 'number'],
section: 'Header & Footer',
},
{
id: 'settings.layout.progressDisplay',
labelKey: _('Reading Progress Style'),
keywords: ['progress', 'display', 'page', 'number', 'percentage'],
section: 'Header & Footer',
},
];
// color panel items
const colorPanelItems = [
{
id: 'settings.color.themeMode',
labelKey: _('Theme Mode'),
keywords: ['theme', 'mode', 'dark', 'light', 'auto', 'system'],
section: 'Theme',
},
{
id: 'settings.color.invertImageInDarkMode',
labelKey: _('Invert Image In Dark Mode'),
keywords: ['invert', 'image', 'dark', 'mode', 'photo'],
section: 'Theme',
},
{
id: 'settings.color.overrideBookColor',
labelKey: _('Override Book Color'),
keywords: ['override', 'book', 'color', 'custom'],
section: 'Theme',
},
{
id: 'settings.color.themeColor',
labelKey: _('Theme Color'),
keywords: ['theme', 'color', 'palette', 'accent'],
section: 'Theme',
},
{
id: 'settings.color.backgroundTexture',
labelKey: _('Background Image'),
keywords: ['background', 'texture', 'image', 'paper', 'pattern'],
section: 'Theme',
},
{
id: 'settings.color.highlightColors',
labelKey: _('Highlight Colors'),
keywords: ['highlight', 'color', 'annotation', 'marker'],
section: 'Highlight',
},
{
id: 'settings.tts.ttsHighlightStyle',
labelKey: _('TTS Highlighting'),
keywords: ['tts', 'highlight', 'style', 'speech', 'read', 'aloud'],
section: 'Highlight',
},
{
id: 'settings.tts.mediaMetadata',
labelKey: _('TTS Media Info Update Frequency'),
keywords: ['tts', 'media', 'metadata', 'bluetooth', 'notification', 'chapter', 'paragraph'],
section: 'TTS',
},
{
id: 'settings.color.readingRuler',
labelKey: _('Reading Ruler'),
keywords: ['reading', 'ruler', 'line', 'guide', 'focus'],
section: 'Reading',
},
{
id: 'settings.color.codeHighlighting',
labelKey: _('Code Highlighting'),
keywords: ['code', 'highlighting', 'syntax', 'programming'],
section: 'Code',
},
];
// control panel items
const controlPanelItems = [
{
id: 'settings.control.scrolledMode',
labelKey: _('Scrolled Mode'),
keywords: ['scroll', 'scrolled', 'mode', 'paginate', 'continuous'],
section: 'Scroll',
},
{
id: 'settings.control.scroll.noContinuousScroll',
labelKey: _('Single Section Scroll'),
keywords: ['single', 'section', 'scroll', 'continuous', 'one', 'chapter'],
section: 'Scroll',
},
{
id: 'settings.control.overlapPixels',
labelKey: _('Overlap Pixels'),
keywords: ['overlap', 'pixels', 'scroll', 'offset'],
section: 'Scroll',
},
{
id: 'settings.control.clickToPaginate',
labelKey: _('Click to Paginate'),
keywords: ['click', 'tap', 'paginate', 'page', 'turn'],
section: 'Pagination',
},
{
id: 'settings.control.clickBothSides',
labelKey: _('Click Both Sides'),
keywords: ['click', 'tap', 'both', 'sides', 'fullscreen'],
section: 'Pagination',
},
{
id: 'settings.control.swapClickSides',
labelKey: _('Swap Click Sides'),
keywords: ['swap', 'click', 'tap', 'sides', 'reverse'],
section: 'Pagination',
},
{
id: 'settings.control.disableDoubleClick',
labelKey: _('Disable Double Click'),
keywords: ['disable', 'double', 'click', 'tap'],
section: 'Pagination',
},
{
id: 'settings.control.showPaginationButtons',
labelKey: _('Show Page Navigation Buttons'),
keywords: ['show', 'pagination', 'buttons', 'navigation', 'arrows', 'chevron', 'page', 'turn'],
section: 'Pagination',
},
{
id: 'settings.control.enableQuickActions',
labelKey: _('Enable Quick Actions'),
keywords: ['quick', 'actions', 'annotation', 'enable'],
section: 'Annotation Tools',
},
{
id: 'settings.control.quickAction',
labelKey: _('Quick Action'),
keywords: ['quick', 'action', 'annotation', 'highlight', 'copy'],
section: 'Annotation Tools',
},
{
id: 'settings.control.copyToNotebook',
labelKey: _('Copy to Notebook'),
keywords: ['copy', 'notebook', 'annotation', 'excerpt'],
section: 'Annotation Tools',
},
{
id: 'settings.control.pagingAnimation',
labelKey: _('Paging Animation'),
keywords: ['paging', 'animation', 'transition', 'effect'],
section: 'Animation',
},
{
id: 'settings.control.einkMode',
labelKey: _('E-Ink Mode'),
keywords: ['eink', 'e-ink', 'kindle', 'e-reader', 'epaper'],
section: 'Device',
},
{
id: 'settings.control.colorEinkMode',
labelKey: _('Color E-Ink Mode'),
keywords: ['color', 'eink', 'e-ink', 'kaleido'],
section: 'Device',
},
{
id: 'settings.control.screenWakeLock',
labelKey: _('Keep Screen Awake'),
keywords: ['screen', 'wake', 'lock', 'awake', 'sleep', 'display'],
section: 'Device',
},
{
id: 'settings.control.allowJavascript',
labelKey: _('Allow JavaScript'),
keywords: ['javascript', 'js', 'script', 'security', 'allow'],
section: 'Security',
},
];
// language panel items
const languagePanelItems = [
{
id: 'settings.language.interfaceLanguage',
labelKey: _('Interface Language'),
keywords: ['interface', 'language', 'locale', 'ui', 'translation'],
section: 'Language',
},
{
id: 'settings.language.translationEnabled',
labelKey: _('Enable Translation'),
keywords: ['translation', 'translate', 'enable', 'language'],
section: 'Translation',
},
{
id: 'settings.language.translationProvider',
labelKey: _('Translation Service'),
keywords: ['translation', 'provider', 'google', 'deepl', 'service'],
section: 'Translation',
},
{
id: 'settings.language.targetLanguage',
labelKey: _('Translate To'),
keywords: ['target', 'language', 'translation', 'destination'],
section: 'Translation',
},
{
id: 'settings.language.ttsTextTranslation',
labelKey: _('TTS Text'),
keywords: ['tts', 'text', 'translation', 'speech', 'read'],
section: 'Translation',
},
{
id: 'settings.language.quotationMarks',
labelKey: _('Replace Quotation Marks'),
keywords: ['quotation', 'marks', 'quotes', 'punctuation', 'cjk'],
section: 'Punctuation',
},
{
id: 'settings.language.chineseConversion',
labelKey: _('Convert Simplified and Traditional Chinese'),
keywords: ['chinese', 'conversion', 'simplified', 'traditional', 'cjk'],
section: 'Chinese',
},
];
// ai panel items
const aiPanelItems = [
{
id: 'settings.ai.enableAssistant',
labelKey: _('Enable AI Assistant'),
keywords: ['ai', 'assistant', 'enable', 'chatbot', 'llm'],
section: 'AI',
},
{
id: 'settings.ai.provider',
labelKey: _('AI Provider'),
keywords: ['ai', 'provider', 'ollama', 'gateway', 'service'],
section: 'AI',
},
{
id: 'settings.ai.ollamaUrl',
labelKey: _('Ollama URL'),
keywords: ['ollama', 'url', 'server', 'endpoint', 'api'],
section: 'Ollama',
},
{
id: 'settings.ai.ollamaModel',
labelKey: _('Ollama Model'),
keywords: ['ollama', 'model', 'llama', 'mistral', 'gemma'],
section: 'Ollama',
},
{
id: 'settings.ai.gatewayApiKey',
labelKey: _('API Key'),
keywords: ['api', 'key', 'gateway', 'token', 'secret'],
section: 'AI Gateway',
},
{
id: 'settings.ai.gatewayModel',
labelKey: _('AI Gateway Model'),
keywords: ['gateway', 'model', 'openai', 'gpt', 'claude'],
section: 'AI Gateway',
},
];
// custom panel items
const customPanelItems = [
{
id: 'settings.custom.contentCss',
labelKey: _('Custom Content CSS'),
keywords: ['custom', 'css', 'content', 'style', 'book'],
section: 'Custom CSS',
},
{
id: 'settings.custom.readerUiCss',
labelKey: _('Custom Reader UI CSS'),
keywords: ['custom', 'css', 'reader', 'ui', 'interface'],
section: 'Custom CSS',
},
];
const actionItems = [
{
id: 'action.toggleTheme',
labelKey: _('Theme Mode'),
keywords: ['theme', 'dark', 'light', 'auto', 'mode', 'toggle'],
},
{
id: 'action.fullscreen',
labelKey: _('Fullscreen'),
keywords: ['fullscreen', 'full', 'screen', 'maximize', 'window'],
},
{
id: 'action.alwaysOnTop',
labelKey: _('Always on Top'),
keywords: ['always', 'top', 'pin', 'window', 'float'],
},
{
id: 'action.screenWakeLock',
labelKey: _('Keep Screen Awake'),
keywords: ['screen', 'wake', 'lock', 'awake', 'sleep', 'display'],
},
{
id: 'action.autoUpload',
labelKey: _('Auto Upload Books to Cloud'),
keywords: ['auto', 'upload', 'cloud', 'sync', 'backup'],
},
{
id: 'action.reload',
labelKey: _('Reload Page'),
keywords: ['reload', 'refresh', 'page'],
},
{
id: 'action.openLastBooks',
labelKey: _('Open Last Book on Start'),
keywords: ['open', 'last', 'book', 'start', 'resume'],
},
{
id: 'action.about',
labelKey: _('About Readest'),
keywords: ['about', 'readest', 'version', 'info'],
},
{
id: 'action.telemetry',
labelKey: _('Help improve Readest'),
keywords: ['telemetry', 'analytics', 'improve', 'statistics'],
},
];
export interface CommandRegistryOptions {
_: TranslationFunc;
openSettingsPanel: (panel: SettingsPanelType, itemId?: string) => void;
toggleTheme: () => void;
toggleFullscreen: () => void;
toggleAlwaysOnTop: () => void;
toggleScreenWakeLock: () => void;
toggleAutoUpload: () => void;
reloadPage: () => void;
toggleOpenLastBooks: () => void;
showAbout: () => void;
toggleTelemetry: () => void;
isDesktop: boolean;
// TODO: add reader-specific actions when reader is open (tts, bookmark, etc.)
}
export const buildCommandRegistry = (options: CommandRegistryOptions): CommandItem[] => {
const { _, openSettingsPanel, isDesktop } = options;
const items: CommandItem[] = [];
// helper to create settings item
const createSettingsItem = (
def: { id: string; labelKey: string; keywords: string[]; section?: string },
panel: SettingsPanelType,
panelLabel?: string,
): CommandItem => ({
id: def.id,
labelKey: def.labelKey,
localizedLabel: _(def.labelKey),
keywords: def.keywords,
category: 'settings',
panel,
panelLabel: _(panelLabel ?? panel),
section: def.section,
icon: panelIcons[panel],
action: () => openSettingsPanel(panel, def.id),
});
// add font panel items
for (const def of fontPanelItems) {
items.push(createSettingsItem(def, 'Font'));
}
// add layout panel items
for (const def of layoutPanelItems) {
items.push(createSettingsItem(def, 'Layout'));
}
// add color panel items
for (const def of colorPanelItems) {
items.push(createSettingsItem(def, 'Color'));
}
// add control panel items
for (const def of controlPanelItems) {
items.push(createSettingsItem(def, 'Control', 'Behavior'));
}
// add language panel items
for (const def of languagePanelItems) {
items.push(createSettingsItem(def, 'Language'));
}
// add ai panel items (only in dev, as of now atleast)
if (process.env.NODE_ENV !== 'production') {
for (const def of aiPanelItems) {
items.push(createSettingsItem(def, 'AI'));
}
}
// add custom panel items
for (const def of customPanelItems) {
items.push(createSettingsItem(def, 'Custom'));
}
// add action items
const getThemeIcon = (): IconType => {
const themeMode =
typeof localStorage !== 'undefined' ? localStorage.getItem('themeMode') : 'auto';
return themeMode === 'dark' ? PiMoon : themeMode === 'light' ? PiSun : TbSunMoon;
};
const createActionItem = (def: {
id: string;
action: () => void;
icon?: IconType;
isAvailable?: () => boolean;
}): CommandItem => {
const item = actionItems.find((item) => item.id === def.id);
if (!item) throw new Error(`Action item definition not found for id: ${def.id}`);
return {
id: def.id,
labelKey: item.labelKey,
localizedLabel: _(item.labelKey),
keywords: item.keywords,
icon: def.icon ?? getThemeIcon(),
category: 'actions',
action: def.action,
isAvailable: def.isAvailable,
};
};
items.push(
createActionItem({
id: 'action.toggleTheme',
action: options.toggleTheme,
}),
);
items.push(
createActionItem({
id: 'action.fullscreen',
action: options.toggleFullscreen,
isAvailable: () => isDesktop,
}),
);
items.push(
createActionItem({
id: 'action.alwaysOnTop',
action: options.toggleAlwaysOnTop,
isAvailable: () => isDesktop,
}),
);
items.push(
createActionItem({
id: 'action.screenWakeLock',
action: options.toggleScreenWakeLock,
}),
);
items.push(
createActionItem({
id: 'action.autoUpload',
action: options.toggleAutoUpload,
}),
);
items.push(
createActionItem({
id: 'action.reload',
icon: MdRefresh,
action: options.reloadPage,
}),
);
items.push(
createActionItem({
id: 'action.openLastBooks',
action: options.toggleOpenLastBooks,
isAvailable: () => isDesktop,
}),
);
items.push(
createActionItem({
id: 'action.about',
action: options.showAbout,
}),
);
items.push(
createActionItem({
id: 'action.telemetry',
action: options.toggleTelemetry,
}),
);
return items;
};
// category labels for display
export const getCategoryLabel = (_: TranslationFunc, category: CommandCategory): string => {
switch (category) {
case 'settings':
return _('Settings');
case 'actions':
return _('Actions');
case 'navigation':
return _('Navigation');
default:
return category;
}
};
// get recent commands from localStorage
export const getRecentCommands = (items: CommandItem[], limit = 5): CommandItem[] => {
if (typeof localStorage === 'undefined') return [];
try {
const recentIds = JSON.parse(localStorage.getItem('recentCommands') || '[]') as string[];
return recentIds
.slice(0, limit)
.map((id) => items.find((item) => item.id === id))
.filter((item): item is CommandItem => item !== undefined);
} catch {
return [];
}
};
// track command usage for recent list
export const trackCommandUsage = (commandId: string): void => {
if (typeof localStorage === 'undefined') return;
try {
const recentIds = JSON.parse(localStorage.getItem('recentCommands') || '[]') as string[];
const updated = [commandId, ...recentIds.filter((id) => id !== commandId)].slice(0, 10);
localStorage.setItem('recentCommands', JSON.stringify(updated));
} catch {
// ignore errors
}
};