fix(search): should be able to terminate search when sidebar is invisible (#2863)

This commit is contained in:
Huang Xin
2026-01-04 17:10:49 +01:00
committed by GitHub
parent 604ef65719
commit eed84a059a
5 changed files with 74 additions and 58 deletions
@@ -264,11 +264,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
) : ( ) : (
<MenuItem label={_('Sign In')} Icon={PiUserCircle} onClick={handleUserLogin}></MenuItem> <MenuItem label={_('Sign In')} Icon={PiUserCircle} onClick={handleUserLogin}></MenuItem>
)} )}
<MenuItem
label={_('Auto Upload Books to Cloud')}
toggled={isAutoUpload}
onClick={toggleAutoUploadBooks}
/>
{user && ( {user && (
<MenuItem <MenuItem
label={_('Cloud File Transfers')} label={_('Cloud File Transfers')}
@@ -286,6 +281,11 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
onClick={openTransferQueue} onClick={openTransferQueue}
/> />
)} )}
<MenuItem
label={_('Auto Upload Books to Cloud')}
toggled={isAutoUpload}
onClick={toggleAutoUploadBooks}
/>
{isTauriAppPlatform() && !appService?.isMobile && ( {isTauriAppPlatform() && !appService?.isMobile && (
<MenuItem <MenuItem
label={_('Auto Import on File Open')} label={_('Auto Import on File Open')}
@@ -38,8 +38,8 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
const { getBookData } = useBookDataStore(); const { getBookData } = useBookDataStore();
const { getConfig, setConfig, saveConfig } = useBookDataStore(); const { getConfig, setConfig, saveConfig } = useBookDataStore();
const { getView, getProgress } = useReaderStore(); const { getView, getProgress } = useReaderStore();
const { getSearchNavState, setSearchTerm, setSearchResults, setSearchProgress } = const { setSearchTerm, setSearchResults, setSearchProgress } = useSidebarStore();
useSidebarStore(); const { getSearchNavState, getSearchStatus, setSearchStatus } = useSidebarStore();
const searchNavState = getSearchNavState(bookKey); const searchNavState = getSearchNavState(bookKey);
const { searchTerm } = searchNavState; const { searchTerm } = searchNavState;
@@ -65,14 +65,12 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
const addToHistory = useCallback( const addToHistory = useCallback(
(term: string) => { (term: string) => {
setSearchHistory((prev) => { const filtered = searchHistory.filter((t) => t !== term);
const filtered = prev.filter((t) => t !== term); const updated = [term, ...filtered].slice(0, MAX_SEARCH_HISTORY);
const updated = [term, ...filtered].slice(0, MAX_SEARCH_HISTORY); localStorage.setItem(historyStorageKey, JSON.stringify(updated));
localStorage.setItem(historyStorageKey, JSON.stringify(updated)); setSearchHistory(updated);
return updated;
});
}, },
[historyStorageKey], [historyStorageKey, searchHistory],
); );
const handleHistoryClick = (term: string) => { const handleHistoryClick = (term: string) => {
@@ -176,13 +174,15 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
inputRef.current.onfocus = () => { inputRef.current.onfocus = () => {
inputFocusedRef.current = true; inputFocusedRef.current = true;
}; };
inputRef.current.focus(); if (!appService?.isMobile) {
inputRef.current.focus();
}
} }
if (isVisible && searchTerm) { if (isVisible && searchTerm) {
handleSearchTermChange(searchTerm); handleSearchTermChange(searchTerm);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVisible]); }, [appService, isVisible]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -236,6 +236,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
// Reset progress at start of search // Reset progress at start of search
setSearchProgress(bookKey, 0); setSearchProgress(bookKey, 0);
setSearchStatus(bookKey, 'searching');
const { section } = progress; const { section } = progress;
const index = searchConfig.scope === 'section' ? section.current : undefined; const index = searchConfig.scope === 'section' ? section.current : undefined;
@@ -253,8 +254,13 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
const processResults = async () => { const processResults = async () => {
for await (const result of generator) { for await (const result of generator) {
if (getSearchStatus(bookKey) === 'terminated') {
console.log('search terminated');
return;
}
if (typeof result === 'string') { if (typeof result === 'string') {
if (result === 'done') { if (result === 'done') {
setSearchStatus(bookKey, 'completed');
setSearchResults(bookKey, [...results]); setSearchResults(bookKey, [...results]);
setSearchProgress(bookKey, 1); setSearchProgress(bookKey, 1);
if (results.length > 0) { if (results.length > 0) {
@@ -6,8 +6,8 @@ import { flattenSearchResults } from '../components/sidebar/SearchResultsNav';
export function useSearchNav(bookKey: string) { export function useSearchNav(bookKey: string) {
const { getView, getProgress } = useReaderStore(); const { getView, getProgress } = useReaderStore();
const { setSideBarVisible, getSearchNavState, setSearchResultIndex, clearSearch } = const { setSideBarVisible } = useSidebarStore();
useSidebarStore(); const { getSearchNavState, setSearchResultIndex, clearSearch } = useSidebarStore();
const searchNavState = getSearchNavState(bookKey); const searchNavState = getSearchNavState(bookKey);
const { searchTerm, searchResults, searchResultIndex, searchProgress } = searchNavState; const { searchTerm, searchResults, searchResultIndex, searchProgress } = searchNavState;
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef } from 'react';
import { useEnv } from '@/context/EnvContext'; import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore'; import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore'; import { useBookDataStore } from '@/store/bookDataStore';
@@ -27,7 +27,6 @@ export const useTextSelector = (
const isTouchStarted = useRef(false); const isTouchStarted = useRef(false);
const selectionPosition = useRef<number | null>(null); const selectionPosition = useRef<number | null>(null);
const lastPointerType = useRef<string>('mouse'); const lastPointerType = useRef<string>('mouse');
const [textSelected, setTextSelected] = useState(false);
const isValidSelection = (sel: Selection) => { const isValidSelection = (sel: Selection) => {
return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0; return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0;
@@ -47,7 +46,7 @@ export const useTextSelector = (
}; };
const makeSelection = async (sel: Selection, index: number, rebuildRange = false) => { const makeSelection = async (sel: Selection, index: number, rebuildRange = false) => {
isTextSelected.current = true; isTextSelected.current = true;
setTextSelected(true); isUpToPopup.current = true;
const range = sel.getRangeAt(0); const range = sel.getRangeAt(0);
if (rebuildRange) { if (rebuildRange) {
sel.removeAllRanges(); sel.removeAllRanges();
@@ -64,7 +63,7 @@ export const useTextSelector = (
// FIXME: extremely hacky way to dismiss system selection tools on iOS // FIXME: extremely hacky way to dismiss system selection tools on iOS
const makeSelectionOnIOS = async (sel: Selection, index: number) => { const makeSelectionOnIOS = async (sel: Selection, index: number) => {
isTextSelected.current = true; isTextSelected.current = true;
setTextSelected(true); isUpToPopup.current = true;
const range = sel.getRangeAt(0); const range = sel.getRangeAt(0);
setTimeout(() => { setTimeout(() => {
sel.removeAllRanges(); sel.removeAllRanges();
@@ -81,25 +80,6 @@ export const useTextSelector = (
}, 30); }, 30);
}, 30); }, 30);
}; };
const handleSelectionchange = (doc: Document) => {
// Available on iOS, Android and Desktop, fired when the selection is changed
// Ideally the popup only shows when the selection is done,
const sel = doc.getSelection() as Selection;
if (osPlatform === 'ios' || appService?.isIOSApp) return;
if (!isValidSelection(sel)) {
if (!isUpToPopup.current) {
handleDismissPopup();
isTextSelected.current = false;
setTextSelected(false);
}
if (isPopuped.current) {
isUpToPopup.current = false;
}
return;
}
isUpToPopup.current = true;
};
const isPointerInsideSelection = (selection: Selection, ev: PointerEvent) => { const isPointerInsideSelection = (selection: Selection, ev: PointerEvent) => {
if (selection.rangeCount === 0) return false; if (selection.rangeCount === 0) return false;
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
@@ -143,18 +123,38 @@ export const useTextSelector = (
const handleTouchEnd = () => { const handleTouchEnd = () => {
isTouchStarted.current = false; isTouchStarted.current = false;
}; };
const handleSelectionchange = (doc: Document) => {
// Available on iOS, Android and Desktop, fired when the selection is changed
if (osPlatform !== 'android' || !appService?.isAndroidApp) return;
const sel = doc.getSelection() as Selection;
if (isValidSelection(sel)) {
isTextSelected.current = true;
isUpToPopup.current = true;
if (!selectionPosition.current) {
selectionPosition.current = view?.renderer?.start || null;
}
} else {
if (!isUpToPopup.current) {
handleDismissPopup();
isTextSelected.current = false;
}
if (isPopuped.current) {
isUpToPopup.current = false;
}
selectionPosition.current = null;
}
};
const handleScroll = () => { const handleScroll = () => {
// Prevent the container from scrolling when text is selected in paginated mode // Prevent the container from scrolling when text is selected in paginated mode
// FIXME: this is a workaround for issue #873 // FIXME: this is a workaround for issue #873
// TODO: support text selection across pages // TODO: support text selection across pages
if (osPlatform !== 'android' || !appService?.isAndroidApp) return;
const viewSettings = getViewSettings(bookKey); const viewSettings = getViewSettings(bookKey);
if ( if (viewSettings?.scrolled) return;
appService?.isAndroidApp &&
isTextSelected.current && if (isTextSelected.current && view?.renderer?.containerPosition && selectionPosition.current) {
!viewSettings?.scrolled &&
view?.renderer?.containerPosition &&
selectionPosition.current
) {
console.warn('Keep container position', selectionPosition.current); console.warn('Keep container position', selectionPosition.current);
view.renderer.containerPosition = selectionPosition.current; view.renderer.containerPosition = selectionPosition.current;
} }
@@ -189,15 +189,6 @@ export const useTextSelector = (
return; return;
}; };
useEffect(() => {
if (isTextSelected.current && !selectionPosition.current) {
selectionPosition.current = view?.renderer?.start || null;
} else if (!isTextSelected.current) {
selectionPosition.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [textSelected]);
useEffect(() => { useEffect(() => {
const handleSingleClick = (): boolean => { const handleSingleClick = (): boolean => {
if (isUpToPopup.current) { if (isUpToPopup.current) {
@@ -207,7 +198,6 @@ export const useTextSelector = (
if (isTextSelected.current) { if (isTextSelected.current) {
handleDismissPopup(); handleDismissPopup();
isTextSelected.current = false; isTextSelected.current = false;
setTextSelected(false);
view?.deselect(); view?.deselect();
return true; return true;
} }
@@ -1,6 +1,8 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { BookNote, BookNoteType, BookSearchMatch, BookSearchResult } from '@/types/book'; import { BookNote, BookNoteType, BookSearchMatch, BookSearchResult } from '@/types/book';
type SearchStatus = 'searching' | 'completed' | 'terminated';
// Per-book search navigation state // Per-book search navigation state
interface SearchNavState { interface SearchNavState {
searchTerm: string; searchTerm: string;
@@ -24,6 +26,7 @@ interface SidebarState {
// Per-book navigation states // Per-book navigation states
searchNavStates: Record<string, SearchNavState>; searchNavStates: Record<string, SearchNavState>;
booknotesNavStates: Record<string, BooknotesNavState>; booknotesNavStates: Record<string, BooknotesNavState>;
searchStatuses: Record<string, SearchStatus>;
getIsSideBarVisible: () => boolean; getIsSideBarVisible: () => boolean;
getSideBarWidth: () => string; getSideBarWidth: () => string;
setSideBarBookKey: (key: string) => void; setSideBarBookKey: (key: string) => void;
@@ -35,6 +38,8 @@ interface SidebarState {
// Search actions (per bookKey) // Search actions (per bookKey)
getSearchNavState: (bookKey: string) => SearchNavState; getSearchNavState: (bookKey: string) => SearchNavState;
setSearchTerm: (bookKey: string, term: string) => void; setSearchTerm: (bookKey: string, term: string) => void;
setSearchStatus: (bookKey: string, status: SearchStatus) => void;
getSearchStatus: (bookKey: string) => SearchStatus | null;
setSearchResults: ( setSearchResults: (
bookKey: string, bookKey: string,
results: BookSearchResult[] | BookSearchMatch[] | null, results: BookSearchResult[] | BookSearchMatch[] | null,
@@ -71,6 +76,7 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
// Per-book navigation states // Per-book navigation states
searchNavStates: {}, searchNavStates: {},
booknotesNavStates: {}, booknotesNavStates: {},
searchStatuses: {},
getIsSideBarVisible: () => get().isSideBarVisible, getIsSideBarVisible: () => get().isSideBarVisible,
getSideBarWidth: () => get().sideBarWidth, getSideBarWidth: () => get().sideBarWidth,
setSideBarBookKey: (key: string) => set({ sideBarBookKey: key }), setSideBarBookKey: (key: string) => set({ sideBarBookKey: key }),
@@ -80,6 +86,9 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
setSideBarVisible: (visible: boolean) => set({ isSideBarVisible: visible }), setSideBarVisible: (visible: boolean) => set({ isSideBarVisible: visible }),
setSideBarPin: (pinned: boolean) => set({ isSideBarPinned: pinned }), setSideBarPin: (pinned: boolean) => set({ isSideBarPinned: pinned }),
// Search actions // Search actions
getSearchStatus: (bookKey: string) => {
return get().searchStatuses[bookKey] || null;
},
getSearchNavState: (bookKey: string) => { getSearchNavState: (bookKey: string) => {
return get().searchNavStates[bookKey] || defaultSearchNavState; return get().searchNavStates[bookKey] || defaultSearchNavState;
}, },
@@ -129,6 +138,17 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
...state.searchNavStates, ...state.searchNavStates,
[bookKey]: { ...defaultSearchNavState }, [bookKey]: { ...defaultSearchNavState },
}, },
searchStatuses: {
...state.searchStatuses,
[bookKey]: 'terminated',
},
})),
setSearchStatus: (bookKey: string, status: SearchStatus) =>
set((state) => ({
searchStatuses: {
...state.searchStatuses,
[bookKey]: status,
},
})), })),
// Booknotes navigation actions // Booknotes navigation actions
getBooknotesNavState: (bookKey: string) => { getBooknotesNavState: (bookKey: string) => {