feat(search): support search terms history, closes #2836 (#2859)

This commit is contained in:
Huang Xin
2026-01-04 07:47:43 +01:00
committed by GitHub
parent 69a51c5880
commit 483d536ca4
2 changed files with 107 additions and 7 deletions
@@ -1,6 +1,8 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FaSearch, FaChevronDown } from 'react-icons/fa';
import { IoMdCloseCircle } from 'react-icons/io';
import { MdDeleteOutline } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
@@ -18,6 +20,8 @@ import SearchOptions from './SearchOptions';
const MINIMUM_SEARCH_TERM_LENGTH_DEFAULT = 2;
const MINIMUM_SEARCH_TERM_LENGTH_CJK = 1;
const SEARCH_HISTORY_KEY = 'search-history';
const MAX_SEARCH_HISTORY = 10;
interface SearchBarProps {
isVisible: boolean;
@@ -34,11 +38,56 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
const { getView, getProgress } = useReaderStore();
const { getSearchNavState, setSearchTerm, setSearchResults } = useSidebarStore();
const searchNavState = getSearchNavState(bookKey);
const { searchTerm } = searchNavState;
const queuedSearchTerm = useRef('');
const inputRef = useRef<HTMLInputElement>(null);
const inputFocusedRef = useRef(false);
const bookHash = useMemo(() => bookKey.split('-')[0]!, [bookKey]);
const historyStorageKey = useMemo(() => `${SEARCH_HISTORY_KEY}-${bookHash}`, [bookHash]);
const [searchHistory, setSearchHistory] = useState<string[]>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem(historyStorageKey);
return saved ? JSON.parse(saved) : [];
}
return [];
});
useEffect(() => {
const saved = localStorage.getItem(historyStorageKey);
setSearchHistory(saved ? JSON.parse(saved) : []);
}, [historyStorageKey]);
const addToHistory = useCallback(
(term: string) => {
setSearchHistory((prev) => {
const filtered = prev.filter((t) => t !== term);
const updated = [term, ...filtered].slice(0, MAX_SEARCH_HISTORY);
localStorage.setItem(historyStorageKey, JSON.stringify(updated));
return updated;
});
},
[historyStorageKey],
);
const handleHistoryClick = (term: string) => {
setSearchTerm(bookKey, term);
handleSearchTermChange(term);
};
const handleClearInput = () => {
setSearchTerm(bookKey, '');
resetSearch();
inputRef.current?.focus();
};
const handleClearHistory = () => {
setSearchHistory([]);
localStorage.removeItem(historyStorageKey);
};
const view = getView(bookKey)!;
const config = getConfig(bookKey)!;
const bookData = getBookData(bookKey)!;
@@ -90,7 +139,6 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
const value = e.target.value;
setSearchTerm(bookKey, value);
handleSearchTermChange(value);
queuedSearchTerm.current = value;
};
const handleSearchConfigChange = (searchConfig: BookSearchConfig) => {
@@ -128,6 +176,9 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
if (typeof result === 'string') {
if (result === 'done') {
setSearchResults(bookKey, [...results]);
if (results.length > 0) {
addToHistory(term);
}
console.log('search done');
}
} else {
@@ -137,7 +188,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
console.log('search progress:', result.progress);
lastProgressLogTime = now;
}
if (queuedSearchTerm.current && queuedSearchTerm.current !== term) {
if (queuedSearchTerm.current !== term) {
console.log('search term changed, resetting search');
resetSearch();
return;
@@ -155,7 +206,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
processResults();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[progress, searchConfig, setSearchResults],
[progress, searchConfig, setSearchResults, addToHistory],
);
const resetSearch = useCallback(() => {
@@ -166,6 +217,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSearchTermChange = useCallback(
debounce((term: string) => {
queuedSearchTerm.current = term;
if (exceedMinSearchTermLength(term)) {
handleSearch(term);
} else {
@@ -176,7 +228,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
);
return (
<div className='relative p-2'>
<div className='relative flex flex-col gap-3 p-2'>
<div className='bg-base-100 flex h-8 items-center rounded-lg'>
<div className='pl-3'>
<FaSearch size={iconSize16} className='text-base-content/50' />
@@ -189,9 +241,19 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
spellCheck={false}
onChange={handleInputChange}
placeholder={_('Search...')}
className='w-full bg-transparent p-2 font-sans text-sm font-light focus:outline-none'
className='w-full bg-transparent p-2 pr-0 font-sans text-sm font-light focus:outline-none'
/>
{searchTerm && (
<button
onClick={handleClearInput}
className='flex h-8 w-8 items-center justify-center bg-transparent pe-2'
aria-label={_('Clear search')}
>
<IoMdCloseCircle size={iconSize16} className='text-base-content/75' />
</button>
)}
<div className='bg-base-300 flex h-8 w-8 items-center rounded-r-lg'>
<Dropdown
label={_('Search Options')}
@@ -210,6 +272,44 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
</Dropdown>
</div>
</div>
{searchHistory.length > 0 && !searchTerm && (
<div className='relative flex'>
<div
className='from-base-200 pointer-events-none absolute left-0 top-0 h-full w-3 bg-gradient-to-r to-transparent'
aria-hidden='true'
/>
<div
className='scrollbar-hidden flex flex-1 gap-1.5 overflow-x-auto'
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{searchHistory.map((term, index) => (
<button
key={index}
onClick={() => handleHistoryClick(term)}
className='hover:bg-base-content/20 text-base-content/70 bg-base-100 flex-shrink-0 whitespace-nowrap rounded-full px-3 py-0.5 text-xs'
>
{term}
</button>
))}
</div>
<div
className='from-base-200 pointer-events-none absolute right-6 top-0 h-full w-6 bg-gradient-to-l to-transparent'
aria-hidden='true'
/>
<button
onClick={handleClearHistory}
className={clsx(
'text-base-content/50 hover:text-base-content/80 bg-base-200 flex-shrink-0 items-center',
'flex h-6 min-h-6 w-8 min-w-8 items-center justify-center p-0',
)}
title={_('Clear history')}
aria-label={_('Clear history')}
>
<MdDeleteOutline size={iconSize16} />
</button>
</div>
)}
</div>
);
};
+1 -1
View File
@@ -268,7 +268,7 @@ foliate-fxl {
}
.search-bar-visible {
max-height: 48px;
max-height: 100%;
visibility: visible;
}