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:
@@ -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(() => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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');
|
||||
@@ -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) {
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 20ab3ec1fd...982f168c66
Reference in New Issue
Block a user