Files
readest/apps/readest-app/src/app/reader/components/sidebar/SearchBar.tsx
T
Huang Xin cd67f6ec3f Various enhancements and fixes (#70)
* Fix excerpts cannot be deleted in Chrome browsers

* Show book details in reader sidebar

* Set minimum search term length to 1 for CJK languages

* Use more system fonts as custom fonts, closes #31
2024-12-31 01:08:27 +01:00

187 lines
5.6 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { FaSearch, FaChevronDown } from 'react-icons/fa';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { BookSearchConfig, BookSearchResult } from '@/types/book';
import Dropdown from '@/components/Dropdown';
import SearchOptions from './SearchOptions';
const MINIMUM_SEARCH_TERM_LENGTH_DEFAULT = 2;
const MINIMUM_SEARCH_TERM_LENGTH_CJK = 1;
interface SearchBarProps {
isVisible: boolean;
bookKey: string;
searchTerm: string;
onSearchResultChange: (results: BookSearchResult[]) => void;
}
const SearchBar: React.FC<SearchBarProps> = ({
isVisible,
bookKey,
searchTerm: term,
onSearchResultChange,
}) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getConfig, saveConfig } = useBookDataStore();
const { getView, getProgress } = useReaderStore();
const [searchTerm, setSearchTerm] = useState(term);
const inputRef = useRef<HTMLInputElement>(null);
const view = getView(bookKey)!;
const config = getConfig(bookKey)!;
const progress = getProgress(bookKey)!;
const searchConfig = config.searchConfig! as BookSearchConfig;
const queuedSearchTerm = useRef('');
const isSearchPending = useRef(false);
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
handleSearchTermChange(searchTerm);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey]);
useEffect(() => {
setSearchTerm(term);
handleSearchTermChange(term);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [term]);
useEffect(() => {
if (isVisible && inputRef.current) {
inputRef.current.focus();
}
}, [isVisible]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && inputRef.current) {
inputRef.current.blur();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
if (searchTimeout.current) {
clearTimeout(searchTimeout.current);
}
};
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchTerm(value);
if (searchTimeout.current) {
clearTimeout(searchTimeout.current);
}
searchTimeout.current = setTimeout(() => {
if (!isSearchPending.current) {
handleSearchTermChange(value);
} else {
queuedSearchTerm.current = value;
}
}, 500);
};
const handleSearchConfigChange = (searchConfig: BookSearchConfig) => {
config.searchConfig = searchConfig;
saveConfig(envConfig, bookKey, config, settings);
handleSearchTermChange(searchTerm);
};
const exceedMinSearchTermLength = (searchTerm: string) => {
const isCJK = /[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]/.test(searchTerm);
const minLength = isCJK ? MINIMUM_SEARCH_TERM_LENGTH_CJK : MINIMUM_SEARCH_TERM_LENGTH_DEFAULT;
return searchTerm.length >= minLength;
};
const handleSearchTermChange = (term: string) => {
if (exceedMinSearchTermLength(term)) {
handleSearch(term);
} else {
resetSearch();
}
};
const handleSearch = async (term: string) => {
console.log('searching for:', term);
isSearchPending.current = true;
const { section } = progress;
const index = searchConfig.scope === 'section' ? section.current : undefined;
const generator = await view.search({ ...searchConfig, query: term, index });
const results: BookSearchResult[] = [];
for await (const result of generator) {
if (typeof result === 'string') {
if (result === 'done') {
onSearchResultChange([...results]);
isSearchPending.current = false;
console.log('search done');
if (
queuedSearchTerm.current !== term &&
exceedMinSearchTermLength(queuedSearchTerm.current)
) {
handleSearch(queuedSearchTerm.current);
}
}
} else {
if (result.progress) {
//console.log('search progress:', result.progress);
} else {
results.push(result);
onSearchResultChange([...results]);
}
}
}
};
const resetSearch = () => {
onSearchResultChange([]);
view?.clearSearch();
};
return (
<div className='relative p-2'>
<div className='bg-base-100 flex h-8 items-center rounded-lg'>
<div className='pl-3'>
<FaSearch className='text-gray-500' />
</div>
<input
ref={inputRef}
type='text'
value={searchTerm}
spellCheck={false}
onChange={handleInputChange}
placeholder={_('Search...')}
className='w-full bg-transparent p-2 font-sans text-sm font-light focus:outline-none'
/>
<div className='flex h-8 w-8 items-center rounded-r-lg bg-gray-300'>
<Dropdown
className='dropdown-bottom flex justify-center'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0 rounded-none rounded-r-lg'
toggleButton={<FaChevronDown size={12} className='text-gray-500' />}
>
<SearchOptions
searchConfig={searchConfig}
onSearchConfigChanged={handleSearchConfigChange}
/>
</Dropdown>
</div>
</div>
</div>
);
};
export default SearchBar;