feat(search): add annotations navigation bar, closes #2060 (#2849)

This commit is contained in:
Huang Xin
2026-01-03 14:57:10 +01:00
committed by GitHub
parent fb41ff5d0c
commit 8a263235ed
43 changed files with 701 additions and 163 deletions
@@ -12,6 +12,7 @@ import { getGridTemplate, getInsetEdges } from '@/utils/grid';
import { getViewInsets } from '@/utils/insets';
import SettingsDialog from '@/components/settings/SettingsDialog';
import SearchResultsNav from './sidebar/SearchResultsNav';
import BooknotesNav from './sidebar/BooknotesNav';
import FoliateViewer from './FoliateViewer';
import SectionInfo from './SectionInfo';
import HeaderBar from './HeaderBar';
@@ -200,6 +201,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
)}
<Annotator bookKey={bookKey} />
<SearchResultsNav bookKey={bookKey} gridInsets={gridInsets} />
<BooknotesNav bookKey={bookKey} gridInsets={gridInsets} toc={bookDoc.toc || []} />
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
<FooterBar
bookKey={bookKey}
@@ -62,7 +62,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
const docs = view?.renderer.getContents() ?? [];
const pointerInDoc = docs.some(({ doc }) => doc.body?.style.cursor === 'pointer');
const pointerInDoc = docs.some(({ doc }) => doc?.body?.style.cursor === 'pointer');
const enableAnnotationQuickActions = viewSettings?.enableAnnotationQuickActions;
const annotationQuickActionButton =
@@ -629,7 +629,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
if (convertChineseVariant && convertChineseVariant !== 'none') {
term = runSimpleCC(term, convertChineseVariant, true);
}
eventDispatcher.dispatch('search', { term });
eventDispatcher.dispatch('search-term', { term, bookKey });
};
const handleDictionary = () => {
@@ -42,7 +42,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
const isVisible = hoveredBookKey === bookKey;
const docs = view?.renderer.getContents() ?? [];
const pointerInDoc = docs.some(({ doc }) => doc.body.style.cursor === 'pointer');
const pointerInDoc = docs.some(({ doc }) => doc?.body?.style.cursor === 'pointer');
const progressInfo = useMemo(
() => (bookFormat === 'PDF' ? section : pageinfo),
@@ -20,9 +20,10 @@ import TextEditor, { TextEditorRef } from '@/components/TextEditor';
interface BooknoteItemProps {
bookKey: string;
item: BookNote;
onClick?: () => void;
}
const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, onClick }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
@@ -43,6 +44,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
event.preventDefault();
eventDispatcher.dispatch('navigate', { bookKey, cfi });
onClick?.();
getView(bookKey)?.goTo(cfi);
if (note) {
setNotebookVisible(true);
@@ -1,7 +1,7 @@
import React from 'react';
import * as CFI from 'foliate-js/epubcfi.js';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { findTocItemBS } from '@/utils/toc';
import { TOCItem } from '@/libs/document';
import { BooknoteGroup, BookNoteType } from '@/types/book';
@@ -13,7 +13,9 @@ const BooknoteView: React.FC<{
toc: TOCItem[];
}> = ({ type, bookKey, toc }) => {
const { getConfig } = useBookDataStore();
const { setActiveBooknoteType, setBooknoteResults } = useSidebarStore();
const config = getConfig(bookKey)!;
const { booknotes: allNotes = [] } = config;
const booknotes = allNotes.filter((note) => note.type === type && !note.deletedAt);
@@ -39,6 +41,14 @@ const BooknoteView: React.FC<{
return a.id - b.id;
});
const handleBrowseBookNotes = () => {
if (booknotes.length === 0) return;
const sorted = [...booknotes].sort((a, b) => CFI.compare(a.cfi, b.cfi));
setActiveBooknoteType(bookKey, type);
setBooknoteResults(bookKey, sorted);
};
return (
<div className='rounded pt-2'>
<ul role='tree' className='px-2'>
@@ -47,7 +57,12 @@ const BooknoteView: React.FC<{
<h3 className='content font-size-base line-clamp-1 font-normal'>{group.label}</h3>
<ul>
{group.booknotes.map((item, index) => (
<BooknoteItem key={`${index}-${item.cfi}`} bookKey={bookKey} item={item} />
<BooknoteItem
key={`${index}-${item.cfi}`}
bookKey={bookKey}
item={item}
onClick={handleBrowseBookNotes}
/>
))}
</ul>
</li>
@@ -0,0 +1,66 @@
import React from 'react';
import { Insets } from '@/types/misc';
import { TOCItem } from '@/libs/document';
import { useTranslation } from '@/hooks/useTranslation';
import { useBooknotesNav } from '../../hooks/useBooknotesNav';
import ContentNavBar from './ContentNavBar';
interface BooknotesNavProps {
bookKey: string;
gridInsets: Insets;
toc: TOCItem[];
}
const BooknotesNav: React.FC<BooknotesNavProps> = ({ bookKey, gridInsets, toc }) => {
const {
activeBooknoteType,
currentSection,
showBooknotesNav,
hasPreviousPage,
hasNextPage,
handleShowResults,
handleClose,
handlePrevious,
handleNext,
} = useBooknotesNav(bookKey, toc);
const _ = useTranslation();
if (!showBooknotesNav) {
return null;
}
const getShowResultsTitle = () => {
switch (activeBooknoteType) {
case 'bookmark':
return _('Bookmarks');
case 'annotation':
return _('Annotations');
case 'excerpt':
return _('Excerpts');
default:
return '';
}
};
return (
<ContentNavBar
bookKey={bookKey}
gridInsets={gridInsets}
title={getShowResultsTitle()}
section={currentSection}
hasPrevious={hasPreviousPage}
hasNext={hasNextPage}
previousTitle={_('Previous')}
nextTitle={_('Next')}
showResultsTitle={getShowResultsTitle()}
closeTitle={_('Close')}
onShowResults={handleShowResults}
onClose={handleClose}
onPrevious={handlePrevious}
onNext={handleNext}
/>
);
};
export default BooknotesNav;
@@ -0,0 +1,133 @@
import clsx from 'clsx';
import React from 'react';
import { IoIosList, IoMdCloseCircle } from 'react-icons/io';
import { HiArrowLongLeft, HiArrowLongRight } from 'react-icons/hi2';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useReaderStore } from '@/store/readerStore';
interface ContentNavBarProps {
bookKey: string;
gridInsets: Insets;
title: string;
section?: string;
showListButton?: boolean;
hasPrevious: boolean;
hasNext: boolean;
previousLabel?: string;
nextLabel?: string;
previousTitle?: string;
nextTitle?: string;
showResultsTitle?: string;
closeTitle?: string;
onShowResults?: () => void;
onClose: () => void;
onPrevious: () => void;
onNext: () => void;
}
const ContentNavBar: React.FC<ContentNavBarProps> = ({
bookKey,
gridInsets,
title,
section,
showListButton = true,
hasPrevious,
hasNext,
previousLabel,
nextLabel,
previousTitle,
nextTitle,
showResultsTitle,
closeTitle,
onShowResults,
onClose,
onPrevious,
onNext,
}) => {
const { appService } = useEnv();
const _ = useTranslation();
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const iconSize16 = useResponsiveSize(16);
const iconSize18 = useResponsiveSize(18);
const iconSize20 = useResponsiveSize(20);
const showSection = appService?.isMobile || !viewSettings?.showHeader;
return (
<div
className='results-nav-bar pointer-events-none absolute inset-0 z-30 flex flex-col items-center justify-between px-4 py-1'
style={{
top: gridInsets.top,
right: gridInsets.right,
bottom: gridInsets.bottom / 4,
left: gridInsets.left,
}}
>
{/* Top bar: Info */}
<div className='bg-base-200 pointer-events-auto flex items-center justify-between rounded-xl px-4 py-1 shadow-lg sm:gap-6'>
{showListButton && onShowResults ? (
<button
title={showResultsTitle || _('Show Results')}
onClick={onShowResults}
className='btn btn-ghost h-8 min-h-8 w-8 p-0 hover:bg-transparent'
>
<IoIosList size={iconSize20} className='text-base-content' />
</button>
) : (
<div className='w-8' />
)}
<div className='flex flex-1 flex-col items-center px-2'>
<span className='line-clamp-1 text-sm font-medium'>{title}</span>
{section && showSection && (
<span className='text-base-content/70 line-clamp-1 text-xs'>{section}</span>
)}
</div>
<button
title={closeTitle || _('Close')}
onClick={onClose}
className='btn btn-ghost h-8 min-h-8 w-8 p-0 hover:bg-transparent'
>
<IoMdCloseCircle size={iconSize16} />
</button>
</div>
{/* Bottom bar: Navigation buttons */}
<div className='bg-base-200 pointer-events-auto flex items-center justify-between gap-6 rounded-xl px-4 py-0 shadow-lg'>
<button
title={previousTitle || _('Previous')}
onClick={onPrevious}
disabled={!hasPrevious}
className={clsx(
'btn btn-ghost flex h-auto min-h-0 flex-1 flex-col items-center gap-0 p-1 hover:bg-transparent',
!hasPrevious && 'opacity-40 disabled:bg-transparent',
)}
>
<HiArrowLongLeft size={iconSize18} className='text-base-content' />
<span className='text-sm font-medium'>{previousLabel || _('Previous')}</span>
</button>
<button
title={nextTitle || _('Next')}
onClick={onNext}
disabled={!hasNext}
className={clsx(
'btn btn-ghost flex h-auto min-h-0 flex-1 flex-col items-center gap-0 p-1 hover:bg-transparent',
!hasNext && 'opacity-40 disabled:bg-transparent',
)}
>
<HiArrowLongRight size={iconSize18} className='text-base-content' />
<span className='text-sm font-medium'>{nextLabel || _('Next')}</span>
</button>
</div>
</div>
);
};
export default ContentNavBar;
@@ -32,7 +32,9 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
const { getBookData } = useBookDataStore();
const { getConfig, saveConfig } = useBookDataStore();
const { getView, getProgress } = useReaderStore();
const { searchTerm, setSearchTerm, setSearchResults } = useSidebarStore();
const { getSearchNavState, setSearchTerm, setSearchResults } = useSidebarStore();
const searchNavState = getSearchNavState(bookKey);
const { searchTerm } = searchNavState;
const queuedSearchTerm = useRef('');
const inputRef = useRef<HTMLInputElement>(null);
const inputFocusedRef = useRef(false);
@@ -50,7 +52,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
useEffect(() => {
handleSearchTermChange(searchTerm);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey]);
}, [bookKey, searchTerm]);
useEffect(() => {
if (isVisible && inputRef.current) {
@@ -86,7 +88,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchTerm(value);
setSearchTerm(bookKey, value);
handleSearchTermChange(value);
queuedSearchTerm.current = value;
};
@@ -125,7 +127,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
for await (const result of generator) {
if (typeof result === 'string') {
if (result === 'done') {
setSearchResults([...results]);
setSearchResults(bookKey, [...results]);
console.log('search done');
}
} else {
@@ -142,7 +144,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
}
} else {
results.push(result);
setSearchResults([...results]);
setSearchResults(bookKey, [...results]);
}
}
@@ -157,9 +159,9 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
);
const resetSearch = useCallback(() => {
setSearchResults([]);
setSearchResults(bookKey, []);
view?.clearSearch();
}, [view, setSearchResults]);
}, [bookKey, view, setSearchResults]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSearchTermChange = useCallback(
@@ -1,15 +1,10 @@
import clsx from 'clsx';
import React from 'react';
import { IoIosList, IoMdCloseCircle } from 'react-icons/io';
import { HiArrowLongLeft, HiArrowLongRight } from 'react-icons/hi2';
import { Insets } from '@/types/misc';
import { BookSearchMatch, BookSearchResult } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useSearchNav } from '../../hooks/useSearchNav';
import { useReaderStore } from '@/store/readerStore';
import ContentNavBar from './ContentNavBar';
interface SearchResultsNavProps {
bookKey: string;
@@ -28,87 +23,29 @@ const SearchResultsNav: React.FC<SearchResultsNavProps> = ({ bookKey, gridInsets
handlePreviousResult,
handleNextResult,
} = useSearchNav(bookKey);
const { appService } = useEnv();
const _ = useTranslation();
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const iconSize16 = useResponsiveSize(16);
const iconSize18 = useResponsiveSize(18);
const iconSize20 = useResponsiveSize(20);
if (!showSearchNav) {
return null;
}
const showSection = appService?.isMobile || !viewSettings?.showHeader;
return (
<div
className='search-results-nav pointer-events-none absolute inset-0 z-30 flex flex-col items-center justify-between px-4 py-1'
style={{
top: gridInsets.top,
right: gridInsets.right,
bottom: gridInsets.bottom / 4,
left: gridInsets.left,
}}
>
{/* Top bar: Search info */}
<div className='bg-base-200/95 pointer-events-auto flex items-center justify-between rounded-xl px-4 py-1 shadow-lg backdrop-blur-sm sm:gap-6'>
<button
title={_('Show Search Results')}
onClick={handleShowResults}
className='btn btn-ghost h-8 min-h-8 w-8 p-0 hover:bg-transparent'
>
<IoIosList size={iconSize20} className='text-base-content' />
</button>
<div className='flex flex-1 flex-col items-center px-2'>
<span className='line-clamp-1 text-sm font-medium'>
{_("Search results for '{{term}}'", { term: searchTerm })}
</span>
{currentSection && showSection && (
<span className='text-base-content/70 line-clamp-1 text-xs'>{currentSection}</span>
)}
</div>
<button
title={_('Close Search')}
onClick={handleCloseSearch}
className='btn btn-ghost h-8 min-h-8 w-8 p-0 hover:bg-transparent'
>
<IoMdCloseCircle size={iconSize16} />
</button>
</div>
{/* Bottom bar: Navigation buttons */}
<div className='bg-base-200/95 pointer-events-auto flex items-center justify-between gap-6 rounded-xl px-4 py-0 shadow-lg backdrop-blur-sm'>
<button
title={_('Previous Result')}
onClick={handlePreviousResult}
disabled={!hasPreviousPage}
className={clsx(
'btn btn-ghost flex h-auto min-h-0 flex-1 flex-col items-center gap-0 p-1 hover:bg-transparent',
!hasPreviousPage && 'opacity-40',
)}
>
<HiArrowLongLeft size={iconSize18} className='text-base-content' />
<span className='text-sm font-medium'>{_('Previous')}</span>
</button>
<button
title={_('Next Result')}
onClick={handleNextResult}
disabled={!hasNextPage}
className={clsx(
'btn btn-ghost flex h-auto min-h-0 flex-1 flex-col items-center gap-0 p-1 hover:bg-transparent',
!hasNextPage && 'opacity-40',
)}
>
<HiArrowLongRight size={iconSize18} className='text-base-content' />
<span className='text-sm font-medium'>{_('Next')}</span>
</button>
</div>
</div>
<ContentNavBar
bookKey={bookKey}
gridInsets={gridInsets}
title={_("Search results for '{{term}}'", { term: searchTerm })}
section={currentSection}
hasPrevious={hasPreviousPage}
hasNext={hasNextPage}
previousTitle={_('Previous Result')}
nextTitle={_('Next Result')}
showResultsTitle={_('Show Search Results')}
closeTitle={_('Close Search')}
onShowResults={handleShowResults}
onClose={handleCloseSearch}
onPrevious={handlePreviousResult}
onNext={handleNextResult}
/>
);
};
@@ -33,8 +33,10 @@ const SideBar: React.FC<{
const { appService } = useEnv();
const { updateAppTheme, safeAreaInsets } = useThemeStore();
const { settings } = useSettingsStore();
const { sideBarBookKey, searchTerm, searchResults, setSearchTerm, clearSearch } =
const { sideBarBookKey, setSideBarBookKey, getSearchNavState, setSearchTerm, clearSearch } =
useSidebarStore();
const searchNavState = sideBarBookKey ? getSearchNavState(sideBarBookKey) : null;
const { searchTerm = '', searchResults = null } = searchNavState || {};
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
@@ -55,11 +57,12 @@ const SideBar: React.FC<{
);
const onSearchEvent = async (event: CustomEvent) => {
const { term } = event.detail;
const { term, bookKey } = event.detail;
setSideBarVisible(true);
setSideBarBookKey(bookKey);
setIsSearchBarVisible(true);
if (term !== undefined && term !== null) {
setSearchTerm(term);
setSearchTerm(bookKey, term);
}
};
@@ -85,10 +88,10 @@ const SideBar: React.FC<{
}, [searchTerm]);
useEffect(() => {
eventDispatcher.on('search', onSearchEvent);
eventDispatcher.on('search-term', onSearchEvent);
eventDispatcher.on('navigate', onNavigateEvent);
return () => {
eventDispatcher.off('search', onSearchEvent);
eventDispatcher.off('search-term', onSearchEvent);
eventDispatcher.off('navigate', onNavigateEvent);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -192,7 +195,7 @@ const SideBar: React.FC<{
const handleHideSearchBar = useCallback(() => {
setIsSearchBarVisible(false);
setTimeout(() => {
clearSearch();
if (sideBarBookKey) clearSearch(sideBarBookKey);
}, 100);
getView(sideBarBookKey)?.clearSearch();
// eslint-disable-next-line react-hooks/exhaustive-deps