feat(reader): add regex and nearby-words search modes (#4560) (#4764)

Add Calibre-parity search modes to the reader's full-text search. The
"Match Whole Words" toggle becomes a single-select mode group: Contains,
Whole Words, Regular Expression, Nearby Words.

- Regex and nearby-words matching live in the foliate-js submodule
  (bumped here); the sidebar threads `mode` and `nearbyWords` through.
- Nearby distance is chosen with a "within N words" control (5/10/20/50,
  default 10), not parsed from the query, so trailing numbers stay
  literal search words.
- Per-mode modifiers: Match Diacritics is greyed out for regex (no-op).
- Calm inline error for invalid regex / too-few nearby words, a
  no-results state, and a results-count footer.
- Nearby matches render a segmented excerpt emphasizing each matched
  word and highlight every word in the book.
- BookConfig schema v2 -> v3 migrates the deprecated `matchWholeWords`
  boolean to `mode` (still written for sync back-compat).

Also fix two search interactions:
- option changes (e.g. within-N-words) now take effect immediately by
  reading the latest config at search time instead of a stale closure.
- closing search from the results nav bar now exits the sidebar search
  mode, not just the results (search-bar visibility lifted to the store).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-24 23:23:09 +08:00
committed by GitHub
parent f7124cbeea
commit 163487b5e3
13 changed files with 411 additions and 66 deletions
@@ -0,0 +1,109 @@
// Tests for the Calibre-parity search modes added in #4560:
// - regex : JS RegExp over the section's joined text
// - nearby-words : whole words occurring within N words of each other
//
// These exercise `search(strs, query, opts)` directly (the same unit seam the
// existing excerpt regression test uses), where `strs` is the per-text-node
// string array `textWalker` produces.
import { describe, expect, it } from 'vitest';
import { search } from 'foliate-js/search.js';
interface Excerpt {
pre: string;
match: string;
post: string;
segments?: { text: string; emphasized: boolean }[];
}
interface FlatRange {
startIndex: number;
startOffset: number;
endIndex: number;
endOffset: number;
}
interface Result {
range: FlatRange;
excerpt: Excerpt;
subRanges?: FlatRange[];
}
const run = (strs: string[], query: string, opts: Record<string, unknown>): Result[] =>
[...search(strs, query, opts)] as Result[];
describe('regex mode', () => {
it('matches a pattern and reports the matched text', () => {
const r = run(['the cat sat on the mat'], 'c.t', { mode: 'regex' });
expect(r.map((x) => x.excerpt.match)).toEqual(['cat']);
});
it('is case-insensitive by default and case-sensitive with matchCase', () => {
expect(run(['Cat cat'], 'cat', { mode: 'regex' }).length).toBe(2);
const cased = run(['Cat cat'], 'cat', { mode: 'regex', matchCase: true });
expect(cased.length).toBe(1);
expect(cased[0]!.excerpt.match).toBe('cat');
});
it('maps a match spanning multiple text nodes back to a range', () => {
const r = run(['the qu', 'ick fox'], 'qu.ck', { mode: 'regex' });
expect(r.length).toBe(1);
expect(r[0]!.excerpt.match).toBe('quick');
expect(r[0]!.range.startIndex).toBe(0);
expect(r[0]!.range.endIndex).toBe(1);
});
it('does not loop forever on a zero-width pattern', () => {
expect(run(['abc'], 'x*', { mode: 'regex' })).toEqual([]);
});
it('throws a typed error on an invalid pattern', () => {
expect(() => run(['abc'], '(', { mode: 'regex' })).toThrow(/regular expression/i);
try {
run(['abc'], '(', { mode: 'regex' });
} catch (e) {
expect((e as { code?: string }).code).toBe('INVALID_REGEX');
}
});
});
describe('nearby-words mode', () => {
const opts = (nearbyWords: number) => ({ mode: 'nearby-words', nearbyWords });
it('matches when all words fall within N words of each other', () => {
const r = run(['alpha one two three beta end'], 'alpha beta', opts(10));
expect(r.length).toBe(1);
expect(r[0]!.excerpt.match).toContain('alpha');
expect(r[0]!.excerpt.match).toContain('beta');
expect(r[0]!.subRanges?.length).toBe(2);
});
it('rejects clusters whose word span exceeds N', () => {
expect(run(['alpha one two three beta'], 'alpha beta', opts(2)).length).toBe(0);
});
it('is order independent', () => {
const r = run(['beta x y alpha'], 'alpha beta', opts(10));
expect(r.length).toBe(1);
});
it('emits one cluster per non-overlapping occurrence pair', () => {
const r = run(['alpha beta gamma delta alpha beta'], 'alpha beta', opts(3));
expect(r.length).toBe(2);
});
it('produces a segmented excerpt emphasizing only the matched words', () => {
const r = run(['alpha one beta'], 'alpha beta', opts(10));
const segments = r[0]!.excerpt.segments ?? [];
const emphasized = segments.filter((s) => s.emphasized).map((s) => s.text);
expect(emphasized).toEqual(['alpha', 'beta']);
expect(segments.some((s) => !s.emphasized && s.text.includes('one'))).toBe(true);
});
it('throws when fewer than two distinct words are given', () => {
expect(() => run(['alpha alpha'], 'alpha', opts(10))).toThrow();
try {
run(['alpha'], 'alpha', opts(10));
} catch (e) {
expect((e as { code?: string }).code).toBe('NEARBY_NEEDS_TWO_WORDS');
}
});
});
@@ -145,6 +145,40 @@ describe('BookConfig serialization', () => {
expect(parsed.viewSettings.annotationToolbarItems).toEqual(['copy']);
});
it('migrates v2 search config: matchWholeWords:true -> mode "whole-words"', () => {
const config = deserializeConfig(
JSON.stringify({ schemaVersion: 2, searchConfig: { matchWholeWords: true } }),
globalViewSettings,
defaultSearchConfig,
);
const sc = config.searchConfig as BookSearchConfig;
expect(sc.mode).toBe('whole-words');
expect(sc.matchWholeWords).toBe(true);
expect(sc.nearbyWords).toBe(10);
});
it('migrates v2 search config: matchWholeWords:false -> mode "contains"', () => {
const config = deserializeConfig(
JSON.stringify({ schemaVersion: 2, searchConfig: { matchWholeWords: false } }),
globalViewSettings,
defaultSearchConfig,
);
const sc = config.searchConfig as BookSearchConfig;
expect(sc.mode).toBe('contains');
expect(sc.matchWholeWords).toBe(false);
});
it('preserves an explicit mode and mirrors the deprecated boolean', () => {
const config = deserializeConfig(
JSON.stringify({ schemaVersion: 3, searchConfig: { mode: 'regex' } }),
globalViewSettings,
defaultSearchConfig,
);
const sc = config.searchConfig as BookSearchConfig;
expect(sc.mode).toBe('regex');
expect(sc.matchWholeWords).toBe(false);
});
it('does not migrate annotations when schemaVersion is already 2', () => {
const config = deserializeConfig(
JSON.stringify({
@@ -38,12 +38,12 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
const { getBookData } = useBookDataStore();
const { getConfig, setConfig, saveConfig } = useBookDataStore();
const { getView, getProgress, getViewSettings } = useReaderStore();
const { setSearchTerm, setSearchResults, setSearchProgress } = useSidebarStore();
const { setSearchTerm, setSearchResults, setSearchProgress, setSearchError } = useSidebarStore();
const { getSearchNavState, getSearchStatus, setSearchStatus } = useSidebarStore();
const viewSettings = getViewSettings(bookKey);
const searchNavState = getSearchNavState(bookKey);
const { searchTerm } = searchNavState;
const { searchTerm, searchError } = searchNavState;
const queuedSearchTerm = useRef('');
const inputRef = useRef<HTMLInputElement>(null);
const inputFocusedRef = useRef(false);
@@ -94,9 +94,10 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
const getSearchCacheKey = useCallback((term: string, config: BookSearchConfig) => {
const configStr = JSON.stringify({
scope: config.scope,
mode: config.mode,
matchCase: config.matchCase,
matchWholeWords: config.matchWholeWords,
matchDiacritics: config.matchDiacritics,
nearbyWords: config.nearbyWords,
});
return md5(`${term}-${configStr}`);
}, []);
@@ -158,6 +159,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
const bookData = getBookData(bookKey)!;
const progress = getProgress(bookKey)!;
const primaryLang = bookData.book?.primaryLanguage || 'en';
const searchMode = (config.searchConfig as BookSearchConfig).mode;
const iconSize12 = useResponsiveSize(12);
const iconSize16 = useResponsiveSize(16);
@@ -209,11 +211,14 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
const handleSearchConfigChange = (searchConfig: BookSearchConfig) => {
setConfig(bookKey, { searchConfig: { ...searchConfig } });
saveConfig(envConfig, bookKey, config, settings);
// setConfig is synchronous, so getConfig now returns the merged config to persist.
saveConfig(envConfig, bookKey, getConfig(bookKey)!, settings);
handleSearchTermChange(searchTerm);
};
const exceedMinSearchTermLength = (searchTerm: string) => {
// Regex patterns can be a single character (e.g. \d), so bypass the gate.
if (searchMode === 'regex') return searchTerm.length >= 1;
const minLength = isCJKStr(searchTerm)
? MINIMUM_SEARCH_TERM_LENGTH_CJK
: MINIMUM_SEARCH_TERM_LENGTH_DEFAULT;
@@ -225,7 +230,11 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
async (term: string) => {
console.log('searching for:', term);
const searchConfig = config.searchConfig as BookSearchConfig;
// Read the latest config from the store, not the render closure: an option
// change (e.g. "within N words") calls setConfig then triggers this search
// synchronously, before this callback is recreated — so the closure's
// `config` is stale by one change. getConfig reflects the just-set value.
const searchConfig = getConfig(bookKey)!.searchConfig as BookSearchConfig;
const cachedResults = await getSearchCache(term, searchConfig);
if (cachedResults) {
setSearchResults(bookKey, cachedResults);
@@ -238,6 +247,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
// Reset progress at start of search
setSearchProgress(bookKey, 0);
setSearchStatus(bookKey, 'searching');
setSearchError(bookKey, null);
const { section } = progress;
const index = searchConfig.scope === 'section' ? section.current : undefined;
@@ -256,42 +266,57 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
let lastProgressLogTime = 0;
const processResults = async () => {
for await (const result of generator) {
if (getSearchStatus(bookKey) === 'terminated') {
console.log('search terminated');
return;
}
if (typeof result === 'string') {
if (result === 'done') {
setSearchStatus(bookKey, 'completed');
setSearchResults(bookKey, [...results]);
setSearchProgress(bookKey, 1);
if (results.length > 0) {
addToHistory(term);
await saveSearchCache(term, searchConfig, results);
}
console.log('search done');
try {
for await (const result of generator) {
if (getSearchStatus(bookKey) === 'terminated') {
console.log('search terminated');
return;
}
} else {
if (result.progress) {
setSearchProgress(bookKey, result.progress);
const now = Date.now();
if (now - lastProgressLogTime >= 1000) {
console.log('search progress:', result.progress);
lastProgressLogTime = now;
}
if (queuedSearchTerm.current !== term) {
console.log('search term changed, resetting search');
resetSearch();
return;
if (typeof result === 'string') {
if (result === 'done') {
setSearchStatus(bookKey, 'completed');
setSearchResults(bookKey, [...results]);
setSearchProgress(bookKey, 1);
if (results.length > 0) {
addToHistory(term);
await saveSearchCache(term, searchConfig, results);
}
console.log('search done');
}
} else {
results.push(result);
setSearchResults(bookKey, [...results]);
if (result.progress) {
setSearchProgress(bookKey, result.progress);
const now = Date.now();
if (now - lastProgressLogTime >= 1000) {
console.log('search progress:', result.progress);
lastProgressLogTime = now;
}
if (queuedSearchTerm.current !== term) {
console.log('search term changed, resetting search');
resetSearch();
return;
}
} else {
results.push(result);
setSearchResults(bookKey, [...results]);
}
}
}
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
}
} catch (err) {
const code = (err as { code?: string }).code;
const message =
code === 'INVALID_REGEX'
? _('Invalid regular expression')
: code === 'NEARBY_NEEDS_TWO_WORDS'
? _('Enter at least two words')
: _('Search failed');
if (!code) console.error('search failed:', err);
setSearchError(bookKey, message);
setSearchResults(bookKey, []);
setSearchStatus(bookKey, 'completed');
setSearchProgress(bookKey, 1);
}
};
@@ -300,9 +325,11 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
// eslint-disable-next-line react-hooks/exhaustive-deps
[
progress,
config.searchConfig,
bookKey,
getConfig,
setSearchResults,
setSearchProgress,
setSearchError,
addToHistory,
getSearchCache,
saveSearchCache,
@@ -340,7 +367,13 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
value={searchTerm}
spellCheck={false}
onChange={handleInputChange}
placeholder={_('Search...')}
placeholder={
searchMode === 'regex'
? _('Search with regex')
: searchMode === 'nearby-words'
? _('Words to find near each other')
: _('Search...')
}
className='search-input w-full bg-transparent p-2 pr-0 ps-10 font-sans text-sm font-light focus:outline-none'
/>
@@ -382,6 +415,8 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
</div>
</div>
{searchError && <div className='text-error px-2 text-xs'>{searchError}</div>}
{searchHistory.length > 0 && !searchTerm && (
<div className='relative flex'>
<div
@@ -1,9 +1,10 @@
import clsx from 'clsx';
import React from 'react';
import { MdCheck } from 'react-icons/md';
import { BookSearchConfig } from '@/types/book';
import { BookSearchConfig, SearchMode } from '@/types/book';
import { useTranslation } from '@/hooks/useTranslation';
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
import { DEFAULT_NEARBY_WORDS, modeToWholeWords } from '@/utils/searchConfig';
interface SearchOptionsProps {
isEink: boolean;
@@ -17,12 +18,18 @@ interface OptionProps {
label: string;
isActive: boolean;
onClick: () => void;
disabled?: boolean;
caption?: string;
}
const Option: React.FC<OptionProps> = ({ label, isActive, onClick }) => (
const Option: React.FC<OptionProps> = ({ label, isActive, onClick, disabled, caption }) => (
<button
className='hover:bg-base-300 flex w-full items-center justify-between rounded-md p-2'
onClick={onClick}
disabled={disabled}
className={clsx(
'hover:bg-base-300 flex w-full items-center justify-between rounded-md p-2',
disabled && 'cursor-not-allowed opacity-40 hover:bg-transparent',
)}
onClick={disabled ? undefined : onClick}
>
<div className='flex items-center'>
<span style={{ minWidth: `${useDefaultIconSize()}px` }}>
@@ -30,9 +37,12 @@ const Option: React.FC<OptionProps> = ({ label, isActive, onClick }) => (
</span>
<span className='ml-2'>{label}</span>
</div>
{caption && <span className='text-base-content/50 ml-2 text-xs'>{caption}</span>}
</button>
);
const NEARBY_WORDS_PRESETS = [5, 10, 20, 50];
const SearchOptions: React.FC<SearchOptionsProps> = ({
isEink,
searchConfig,
@@ -41,10 +51,23 @@ const SearchOptions: React.FC<SearchOptionsProps> = ({
setIsDropdownOpen,
}) => {
const _ = useTranslation();
const updateConfig = (key: keyof BookSearchConfig, value: boolean | string) => {
const iconSize = useDefaultIconSize();
// Align nested nearby controls with the option label column, not the checkmark
// icon. An option label sits at button p-2 (8px) + icon width + ml-2 gap (8px);
// this inline padding-inline-start replaces the row's own px-2 left padding.
const labelIndent = `${iconSize + 16}px`;
const updateConfig = (key: keyof BookSearchConfig, value: boolean | string | number) => {
onSearchConfigChanged({ ...searchConfig, [key]: value });
setIsDropdownOpen?.(false);
};
const setMode = (mode: SearchMode) => {
onSearchConfigChanged({ ...searchConfig, mode, matchWholeWords: modeToWholeWords(mode) });
setIsDropdownOpen?.(false);
};
const mode = searchConfig.mode;
// regex matches raw text, so the diacritics modifier has no effect there.
const diacriticsDisabled = mode === 'regex';
return (
<div
@@ -65,19 +88,58 @@ const SearchOptions: React.FC<SearchOptionsProps> = ({
onClick={() => updateConfig('scope', 'section')}
/>
<hr aria-hidden='true' className='border-base-200 my-1' />
<Option
label={_('Contains')}
isActive={mode === 'contains'}
onClick={() => setMode('contains')}
/>
<Option
label={_('Whole Words')}
isActive={mode === 'whole-words'}
onClick={() => setMode('whole-words')}
/>
<Option
label={_('Regular Expression')}
isActive={mode === 'regex'}
onClick={() => setMode('regex')}
/>
<Option
label={_('Nearby Words')}
isActive={mode === 'nearby-words'}
onClick={() => setMode('nearby-words')}
/>
{mode === 'nearby-words' && (
<div className='px-2 py-1' style={{ paddingInlineStart: labelIndent }}>
<div className='text-base-content/70 mb-1 text-xs'>{_('Within N words')}</div>
<div className='flex gap-1'>
{NEARBY_WORDS_PRESETS.map((n) => (
<button
key={n}
className={clsx(
'rounded-md px-2 py-1 text-xs',
(searchConfig.nearbyWords ?? DEFAULT_NEARBY_WORDS) === n
? 'bg-base-300 font-bold'
: 'hover:bg-base-300',
)}
onClick={() => updateConfig('nearbyWords', n)}
>
{n}
</button>
))}
</div>
</div>
)}
<hr aria-hidden='true' className='border-base-200 my-1' />
<Option
label={_('Match Case')}
isActive={searchConfig.matchCase}
onClick={() => updateConfig('matchCase', !searchConfig.matchCase)}
/>
<Option
label={_('Match Whole Words')}
isActive={searchConfig.matchWholeWords}
onClick={() => updateConfig('matchWholeWords', !searchConfig.matchWholeWords)}
/>
<Option
label={_('Match Diacritics')}
isActive={searchConfig.matchDiacritics}
isActive={searchConfig.matchDiacritics && !diacriticsDisabled}
disabled={diacriticsDisabled}
caption={diacriticsDisabled ? _('Not for regex') : undefined}
onClick={() => updateConfig('matchDiacritics', !searchConfig.matchDiacritics)}
/>
</div>
@@ -1,6 +1,8 @@
import React, { useMemo } from 'react';
import { BookSearchMatch, BookSearchResult, SearchExcerpt } from '@/types/book';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { findNearestCfi } from '@/utils/cfi';
import useScrollToItem from '../../hooks/useScrollToItem';
import clsx from 'clsx';
@@ -13,6 +15,34 @@ interface SearchResultItemProps {
onSelectResult: (cfi: string) => void;
}
// nearby-words excerpts emphasize each matched word; other modes bold the single match span.
const ExcerptBody: React.FC<{ excerpt: SearchExcerpt }> = ({ excerpt }) => {
if (excerpt.segments) {
return (
<>
<span>{excerpt.pre}</span>
{excerpt.segments.map((seg, i) =>
seg.emphasized ? (
<span key={i} className='font-bold text-red-500'>
{seg.text}
</span>
) : (
<span key={i}>{seg.text}</span>
),
)}
<span>{excerpt.post}</span>
</>
);
}
return (
<>
<span>{excerpt.pre}</span>
<span className='font-bold text-red-500'>{excerpt.match}</span>
<span>{excerpt.post}</span>
</>
);
};
const SearchResultItem: React.FC<SearchResultItemProps> = ({
bookKey,
cfi,
@@ -44,9 +74,7 @@ const SearchResultItem: React.FC<SearchResultItemProps> = ({
}}
>
<div className='line-clamp-3'>
<span className=''>{excerpt.pre}</span>
<span className='font-bold text-red-500'>{excerpt.match}</span>
<span className=''>{excerpt.post}</span>
<ExcerptBody excerpt={excerpt} />
</div>
</li>
);
@@ -58,8 +86,11 @@ interface SearchResultsProps {
}
const SearchResults: React.FC<SearchResultsProps> = ({ bookKey, results, onSelectResult }) => {
const _ = useTranslation();
const { getProgress } = useReaderStore();
const { getSearchNavState } = useSidebarStore();
const progress = getProgress(bookKey);
const { searchProgress, searchError } = getSearchNavState(bookKey);
const nearestCfi = useMemo(() => {
const allCfis: string[] = [];
@@ -73,6 +104,23 @@ const SearchResults: React.FC<SearchResultsProps> = ({ bookKey, results, onSelec
return findNearestCfi(allCfis, progress?.location);
}, [progress?.location, results]);
const totalMatches = useMemo(
() =>
results.reduce((sum, result) => sum + ('subitems' in result ? result.subitems.length : 1), 0),
[results],
);
// The error itself is surfaced in the search bar; once the search has finished
// with no hits, say so instead of leaving a blank panel.
if (results.length === 0) {
if (searchError || searchProgress < 1) return null;
return (
<div className='search-results text-base-content/60 p-4 text-center text-sm'>
{_('No results found')}
</div>
);
}
return (
<div className='search-results overflow-y-auto p-2 font-sans text-sm font-light'>
<ul className='px-2'>
@@ -109,6 +157,11 @@ const SearchResults: React.FC<SearchResultsProps> = ({ bookKey, results, onSelec
}
})}
</ul>
{searchProgress >= 1 && (
<div className='text-base-content/60 px-2 py-2 text-center text-xs'>
{_('{{count}} results', { count: totalMatches })}
</div>
)}
</div>
);
};
@@ -32,11 +32,11 @@ const SideBar = ({}) => {
const { updateAppTheme, safeAreaInsets, systemUIVisible, statusBarHeight } = useThemeStore();
const { sideBarBookKey, setSideBarBookKey, getSearchNavState, setSearchTerm, clearSearch } =
useSidebarStore();
const { isSearchBarVisible, setSearchBarVisible } = useSidebarStore();
const searchNavState = sideBarBookKey ? getSearchNavState(sideBarBookKey) : null;
const { searchTerm = '', searchResults = null } = searchNavState || {};
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
const searchTermRef = useRef(searchTerm);
const isMobile = window.innerWidth < 640;
const [isFullHeightInMobile, setIsFullHeightInMobile] = useState(isMobile);
@@ -57,7 +57,7 @@ const SideBar = ({}) => {
const { term, bookKey } = event.detail;
setSideBarVisible(true);
setSideBarBookKey(bookKey);
setIsSearchBarVisible(true);
setSearchBarVisible(true);
if (term !== undefined && term !== null) {
setSearchTerm(bookKey, term);
}
@@ -122,22 +122,23 @@ const SideBar = ({}) => {
};
const handleToggleSearchBar = () => {
setIsSearchBarVisible((prev) => {
if (prev) handleHideSearchBar();
return !prev;
});
if (isSearchBarVisible) {
handleHideSearchBar();
} else {
setSearchBarVisible(true);
}
};
const handleShowSearchBar = useCallback(() => {
setTimeout(() => {
setSideBarVisible(true);
setIsSearchBarVisible(true);
setSearchBarVisible(true);
}, 100);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleHideSearchBar = useCallback(() => {
setIsSearchBarVisible(false);
setSearchBarVisible(false);
setTimeout(() => {
if (sideBarBookKey) clearSearch(sideBarBookKey);
}, 100);
@@ -7,7 +7,7 @@ import { flattenSearchResults } from '../components/sidebar/SearchResultsNav';
export function useSearchNav(bookKey: string) {
const getView = useReaderStore((s) => s.getView);
const { setSideBarVisible } = useSidebarStore();
const { setSideBarVisible, setSearchBarVisible } = useSidebarStore();
const { getSearchNavState, setSearchResultIndex, clearSearch } = useSidebarStore();
const searchNavState = getSearchNavState(bookKey);
@@ -83,8 +83,11 @@ export function useSearchNav(bookKey: string) {
const handleCloseSearch = useCallback(() => {
clearSearch(bookKey);
// Exit the sidebar's search mode too, not just the results — otherwise
// reopening the sidebar still shows the (empty) search bar.
setSearchBarVisible(false);
getView(bookKey)?.clearSearch();
}, [clearSearch, bookKey, getView]);
}, [clearSearch, bookKey, getView, setSearchBarVisible]);
// Navigate to the previous page with results (last result before current page)
const handlePreviousResult = useCallback(() => {
+4 -1
View File
@@ -424,9 +424,12 @@ export const DEFAULT_SCREEN_CONFIG: ScreenConfig = {
export const DEFAULT_BOOK_SEARCH_CONFIG: BookSearchConfig = {
scope: 'book',
mode: 'contains',
matchCase: false,
matchWholeWords: false,
matchDiacritics: false,
nearbyWords: 10,
// kept for sync wire back-compat with pre-v3 clients (mirrors mode === 'whole-words')
matchWholeWords: false,
};
export const DEFAULT_VIEW_SETTINGS_CONFIG: ViewSettingsConfig = {
@@ -9,6 +9,7 @@ interface SearchNavState {
searchResults: BookSearchResult[] | BookSearchMatch[] | null;
searchResultIndex: number;
searchProgress: number; // 0 to 1, where 1 means search complete
searchError: string | null; // invalid regex / nearby-words parse error, shown inline
}
// Per-book booknotes navigation state
@@ -23,6 +24,7 @@ interface SidebarState {
sideBarWidth: string;
isSideBarVisible: boolean;
isSideBarPinned: boolean;
isSearchBarVisible: boolean;
// Per-book navigation states
searchNavStates: Record<string, SearchNavState>;
booknotesNavStates: Record<string, BooknotesNavState>;
@@ -35,6 +37,7 @@ interface SidebarState {
toggleSideBarPin: () => void;
setSideBarVisible: (visible: boolean) => void;
setSideBarPin: (pinned: boolean) => void;
setSearchBarVisible: (visible: boolean) => void;
// Search actions (per bookKey)
getSearchNavState: (bookKey: string) => SearchNavState;
setSearchTerm: (bookKey: string, term: string) => void;
@@ -46,6 +49,7 @@ interface SidebarState {
) => void;
setSearchResultIndex: (bookKey: string, index: number) => void;
setSearchProgress: (bookKey: string, progress: number) => void;
setSearchError: (bookKey: string, error: string | null) => void;
clearSearch: (bookKey: string) => void;
// Booknotes navigation actions (per bookKey)
getBooknotesNavState: (bookKey: string) => BooknotesNavState;
@@ -60,6 +64,7 @@ const defaultSearchNavState: SearchNavState = {
searchResults: null,
searchResultIndex: 0,
searchProgress: 1,
searchError: null,
};
const defaultBooknotesNavState: BooknotesNavState = {
@@ -73,6 +78,7 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
sideBarWidth: '',
isSideBarVisible: false,
isSideBarPinned: false,
isSearchBarVisible: false,
// Per-book navigation states
searchNavStates: {},
booknotesNavStates: {},
@@ -85,6 +91,7 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
toggleSideBarPin: () => set((state) => ({ isSideBarPinned: !state.isSideBarPinned })),
setSideBarVisible: (visible: boolean) => set({ isSideBarVisible: visible }),
setSideBarPin: (pinned: boolean) => set({ isSideBarPinned: pinned }),
setSearchBarVisible: (visible: boolean) => set({ isSearchBarVisible: visible }),
// Search actions
getSearchStatus: (bookKey: string) => {
return get().searchStatuses[bookKey] || null;
@@ -132,6 +139,16 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
},
},
})),
setSearchError: (bookKey: string, error: string | null) =>
set((state) => ({
searchNavStates: {
...state.searchNavStates,
[bookKey]: {
...(state.searchNavStates[bookKey] || defaultSearchNavState),
searchError: error,
},
},
})),
clearSearch: (bookKey: string) =>
set((state) => ({
searchNavStates: {
+12 -2
View File
@@ -420,11 +420,17 @@ export interface BookProgress {
page: number;
}
export type SearchMode = 'contains' | 'whole-words' | 'regex' | 'nearby-words';
export interface BookSearchConfig {
scope: 'book' | 'section';
mode: SearchMode;
matchCase: boolean;
matchWholeWords: boolean;
matchDiacritics: boolean;
// nearby-words: maximum number of words separating the matched words
nearbyWords?: number;
/** @deprecated since schema v3 — mirrors `mode === 'whole-words'`; kept for sync wire back-compat. */
matchWholeWords?: boolean;
index?: number;
query?: string;
acceptNode?: (node: Node) => number;
@@ -437,10 +443,14 @@ export interface SearchExcerpt {
pre: string;
match: string;
post: string;
// nearby-words: the cluster window split into matched (emphasized) words and gaps
segments?: { text: string; emphasized: boolean }[];
}
export interface BookSearchMatch {
cfi: string;
// nearby-words: per-word CFIs to highlight (>= 2); absent for single-span matches
cfis?: string[];
excerpt: SearchExcerpt;
}
@@ -451,7 +461,7 @@ export interface BookSearchResult {
progress?: number;
}
export const BOOK_CONFIG_SCHEMA_VERSION = 2;
export const BOOK_CONFIG_SCHEMA_VERSION = 3;
export interface BookConfig {
schemaVersion?: number;
@@ -0,0 +1,10 @@
import { BookSearchConfig, SearchMode } from '@/types/book';
export const DEFAULT_NEARBY_WORDS = 10;
export const modeToWholeWords = (mode: SearchMode): boolean => mode === 'whole-words';
// v2 configs (and pre-v3 sync peers) encode whole-word matching as a boolean and
// have no `mode`. Derive the mode from the boolean when `mode` is absent.
export const ensureSearchMode = (config: Partial<BookSearchConfig>): SearchMode =>
config.mode ?? (config.matchWholeWords ? 'whole-words' : 'contains');
+8
View File
@@ -5,6 +5,7 @@ import {
ViewSettings,
} from '@/types/book';
import { unifyAnnotations } from '@/utils/booknoteMigration';
import { DEFAULT_NEARBY_WORDS, ensureSearchMode, modeToWholeWords } from '@/utils/searchConfig';
export const stampBookConfigSchema = <T extends Partial<BookConfig>>(config: T): T => {
return { ...config, schemaVersion: BOOK_CONFIG_SCHEMA_VERSION };
@@ -74,6 +75,13 @@ export const deserializeConfig = (
const { viewSettings, searchConfig } = config;
config.viewSettings = { ...globalViewSettings, ...viewSettings };
config.searchConfig = { ...defaultSearchConfig, ...searchConfig };
// v2 -> v3: search gained a `mode` enum (contains/whole-words/regex/nearby-words)
// replacing the `matchWholeWords` boolean. Derive `mode` from the boolean when a
// pre-v3 config (or sync peer) omits it, then keep the boolean mirrored on the wire.
const sc = config.searchConfig as BookSearchConfig;
sc.mode = ensureSearchMode(searchConfig ?? {});
sc.matchWholeWords = modeToWholeWords(sc.mode);
sc.nearbyWords ??= DEFAULT_NEARBY_WORDS;
// v1 -> v2: collapse split highlight+note records into one unified record so a
// note renders with its highlight and round-trips cleanly to KOReader.
if ((config.schemaVersion ?? 0) < 2 && config.booknotes?.length) {