forked from akai/readest
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -268,7 +268,7 @@ foliate-fxl {
|
||||
}
|
||||
|
||||
.search-bar-visible {
|
||||
max-height: 48px;
|
||||
max-height: 100%;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user