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
@@ -157,7 +157,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
const showSearchBar = () => {
setTimeout(() => {
eventDispatcher.dispatch('search', { term: null });
eventDispatcher.dispatch('search-term', { term: null, bookKey: sideBarBookKey });
}, 100);
};
@@ -0,0 +1,185 @@
import { useCallback, useMemo } from 'react';
import * as CFI from 'foliate-js/epubcfi.js';
import { useSidebarStore } from '@/store/sidebarStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { isCfiInLocation } from '@/utils/cfi';
import { findTocItemBS } from '@/utils/toc';
import { BookNoteType } from '@/types/book';
import { TOCItem } from '@/libs/document';
export function useBooknotesNav(bookKey: string, toc: TOCItem[]) {
const { getView, getProgress } = useReaderStore();
const { getConfig } = useBookDataStore();
const {
setSideBarVisible,
getBooknotesNavState,
setActiveBooknoteType,
setBooknoteResults,
setBooknoteIndex,
clearBooknotesNav,
} = useSidebarStore();
const booknotesNavState = getBooknotesNavState(bookKey);
const { activeBooknoteType, booknoteResults, booknoteIndex } = booknotesNavState;
const progress = getProgress(bookKey);
const currentLocation = progress?.location;
// Get booknotes from config and filter by type
const allBooknotes = useMemo(() => {
const config = getConfig(bookKey);
return config?.booknotes?.filter((note) => !note.deletedAt) || [];
}, [bookKey, getConfig]);
// Sort booknotes by CFI order
const sortedBooknotes = useMemo(() => {
if (!booknoteResults) return [];
return [...booknoteResults].sort((a, b) => CFI.compare(a.cfi, b.cfi));
}, [booknoteResults]);
const totalResults = sortedBooknotes.length;
const hasBooknotes = booknoteResults && totalResults > 0;
const showBooknotesNav = hasBooknotes && activeBooknoteType !== null;
// Get current section label
const currentSection = useMemo(() => {
if (!sortedBooknotes.length || booknoteIndex >= sortedBooknotes.length) return '';
const currentNote = sortedBooknotes[booknoteIndex];
if (!currentNote) return '';
const tocItem = findTocItemBS(toc, currentNote.cfi);
return tocItem?.label || '';
}, [sortedBooknotes, booknoteIndex, toc]);
// Find booknotes on the current page
const currentPageResults = useMemo(() => {
if (!sortedBooknotes.length || !currentLocation) return { firstIndex: -1, lastIndex: -1 };
let firstIndex = -1;
let lastIndex = -1;
for (let i = 0; i < sortedBooknotes.length; i++) {
const note = sortedBooknotes[i];
if (note && isCfiInLocation(note.cfi, currentLocation)) {
if (firstIndex === -1) firstIndex = i;
lastIndex = i;
}
}
if (firstIndex !== -1) {
setTimeout(() => setBooknoteIndex(bookKey, firstIndex), 0);
}
return { firstIndex, lastIndex };
}, [sortedBooknotes, currentLocation, bookKey, setBooknoteIndex]);
// Navigate to a specific booknote
const navigateToBooknote = useCallback(
(index: number) => {
if (!sortedBooknotes.length) return;
if (index < 0 || index >= sortedBooknotes.length) return;
const note = sortedBooknotes[index];
if (note) {
setBooknoteIndex(bookKey, index);
getView(bookKey)?.goTo(note.cfi);
}
},
[bookKey, sortedBooknotes, setBooknoteIndex, getView],
);
// Start navigation for a specific booknote type
const startNavigation = useCallback(
(type: BookNoteType) => {
const filtered = allBooknotes.filter((note) => note.type === type);
if (filtered.length === 0) return;
const sorted = [...filtered].sort((a, b) => CFI.compare(a.cfi, b.cfi));
setActiveBooknoteType(bookKey, type);
setBooknoteResults(bookKey, sorted);
setBooknoteIndex(bookKey, 0);
// Navigate to first booknote
if (sorted.length > 0) {
getView(bookKey)?.goTo(sorted[0]!.cfi);
}
},
[
allBooknotes,
bookKey,
setActiveBooknoteType,
setBooknoteResults,
setBooknoteIndex,
getView,
],
);
const handleShowResults = useCallback(() => {
setSideBarVisible(true);
}, [setSideBarVisible]);
const handleClose = useCallback(() => {
clearBooknotesNav(bookKey);
}, [clearBooknotesNav, bookKey]);
// Navigate to the previous page with booknotes
const handlePrevious = useCallback(() => {
const { firstIndex } = currentPageResults;
if (firstIndex > 0) {
navigateToBooknote(firstIndex - 1);
} else if (firstIndex === -1 && booknoteIndex > 0) {
navigateToBooknote(booknoteIndex - 1);
}
}, [currentPageResults, booknoteIndex, navigateToBooknote]);
// Navigate to the next page with booknotes
const handleNext = useCallback(() => {
const { lastIndex } = currentPageResults;
if (lastIndex >= 0 && lastIndex < totalResults - 1) {
navigateToBooknote(lastIndex + 1);
} else if (lastIndex === -1 && booknoteIndex < totalResults - 1) {
navigateToBooknote(booknoteIndex + 1);
}
}, [currentPageResults, totalResults, booknoteIndex, navigateToBooknote]);
// Check if there are booknotes before/after the current page
const hasPreviousPage =
currentPageResults.firstIndex > 0 ||
(currentPageResults.firstIndex === -1 && booknoteIndex > 0);
const hasNextPage =
(currentPageResults.lastIndex >= 0 && currentPageResults.lastIndex < totalResults - 1) ||
(currentPageResults.lastIndex === -1 && booknoteIndex < totalResults - 1);
// Get counts for each booknote type
const bookmarkCount = useMemo(
() => allBooknotes.filter((n) => n.type === 'bookmark').length,
[allBooknotes],
);
const annotationCount = useMemo(
() => allBooknotes.filter((n) => n.type === 'annotation').length,
[allBooknotes],
);
const excerptCount = useMemo(
() => allBooknotes.filter((n) => n.type === 'excerpt').length,
[allBooknotes],
);
return {
activeBooknoteType,
currentSection,
booknoteIndex,
totalResults,
showBooknotesNav,
hasPreviousPage,
hasNextPage,
bookmarkCount,
annotationCount,
excerptCount,
startNavigation,
handleShowResults,
handleClose,
handlePrevious,
handleNext,
};
}
@@ -7,15 +7,15 @@ import { flattenSearchResults } from '../components/sidebar/SearchResultsNav';
export function useSearchNav(bookKey: string) {
const { getView, getProgress } = useReaderStore();
const {
sideBarBookKey,
setSideBarVisible,
searchTerm,
searchResults,
searchResultIndex,
getSearchNavState,
setSearchResultIndex,
clearSearch,
} = useSidebarStore();
const searchNavState = getSearchNavState(bookKey);
const { searchTerm, searchResults, searchResultIndex } = searchNavState;
const progress = getProgress(bookKey);
const currentLocation = useMemo(() => {
@@ -53,25 +53,25 @@ export function useSearchNav(bookKey: string) {
}
}
if (firstIndex !== -1) {
setTimeout(() => setSearchResultIndex(firstIndex), 0);
setTimeout(() => setSearchResultIndex(bookKey, firstIndex), 0);
}
return { firstIndex, lastIndex };
}, [flattenedResults, currentLocation, setSearchResultIndex]);
}, [flattenedResults, currentLocation, bookKey, setSearchResultIndex]);
// Navigate to a specific search result
const navigateToResult = useCallback(
(index: number) => {
if (!sideBarBookKey || !flattenedResults.length) return;
if (!flattenedResults.length) return;
if (index < 0 || index >= flattenedResults.length) return;
const result = flattenedResults[index];
if (result) {
setSearchResultIndex(index);
getView(sideBarBookKey)?.goTo(result.cfi);
setSearchResultIndex(bookKey, index);
getView(bookKey)?.goTo(result.cfi);
}
},
[sideBarBookKey, flattenedResults, setSearchResultIndex, getView],
[bookKey, flattenedResults, setSearchResultIndex, getView],
);
const handleShowResults = useCallback(() => {
@@ -79,11 +79,9 @@ export function useSearchNav(bookKey: string) {
}, [setSideBarVisible]);
const handleCloseSearch = useCallback(() => {
clearSearch();
if (sideBarBookKey) {
getView(sideBarBookKey)?.clearSearch();
}
}, [clearSearch, sideBarBookKey, getView]);
clearSearch(bookKey);
getView(bookKey)?.clearSearch();
}, [clearSearch, bookKey, getView]);
// Navigate to the previous page with results (last result before current page)
const handlePreviousResult = useCallback(() => {