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
@@ -869,5 +869,8 @@
"Search results for '{{term}}'": "نتائج البحث عن «{{term}}»",
"Close Search": "إغلاق البحث",
"Previous Result": "النتيجة السابقة",
"Next Result": "النتيجة التالية"
"Next Result": "النتيجة التالية",
"Bookmarks": "الإشارات المرجعية",
"Annotations": "التعليقات التوضيحية",
"Show Results": "عرض النتائج"
}
@@ -829,5 +829,8 @@
"Search results for '{{term}}'": "'{{term}}' এর ফলাফল",
"Close Search": "অনুসন্ধান বন্ধ করুন",
"Previous Result": "আগের ফলাফল",
"Next Result": "পরবর্তী ফলাফল"
"Next Result": "পরবর্তী ফলাফল",
"Bookmarks": "বুকমার্ক",
"Annotations": "টীকা",
"Show Results": "ফলাফল দেখুন"
}
@@ -819,5 +819,8 @@
"Search results for '{{term}}'": "'{{term}}'ཡི་འབྲས་བུ།",
"Close Search": "འཚོལ་བཤེར་སྒོ་རྒྱག",
"Previous Result": "སྔོན་མའི་འབྲས་བུ།",
"Next Result": "རྗེས་མའི་འབྲས་བུ།"
"Next Result": "རྗེས་མའི་འབྲས་བུ།",
"Bookmarks": "དཔེ་རྟགས།",
"Annotations": "མཆན།",
"Show Results": "འབྲས་བུ་སྟོན།"
}
@@ -829,5 +829,8 @@
"Search results for '{{term}}'": "Ergebnisse für '{{term}}'",
"Close Search": "Suche schließen",
"Previous Result": "Vorheriges Ergebnis",
"Next Result": "Nächstes Ergebnis"
"Next Result": "Nächstes Ergebnis",
"Bookmarks": "Lesezeichen",
"Annotations": "Anmerkungen",
"Show Results": "Ergebnisse anzeigen"
}
@@ -829,5 +829,8 @@
"Search results for '{{term}}'": "Αποτελέσματα για '{{term}}'",
"Close Search": "Κλείσιμο αναζήτησης",
"Previous Result": "Προηγούμενο αποτέλεσμα",
"Next Result": "Επόμενο αποτέλεσμα"
"Next Result": "Επόμενο αποτέλεσμα",
"Bookmarks": "Σελιδοδείκτες",
"Annotations": "Σχολιασμοί",
"Show Results": "Εμφάνιση αποτελεσμάτων"
}
@@ -839,5 +839,8 @@
"Search results for '{{term}}'": "Resultados para '{{term}}'",
"Close Search": "Cerrar búsqueda",
"Previous Result": "Resultado anterior",
"Next Result": "Resultado siguiente"
"Next Result": "Resultado siguiente",
"Bookmarks": "Marcadores",
"Annotations": "Anotaciones",
"Show Results": "Mostrar resultados"
}
@@ -829,5 +829,8 @@
"Search results for '{{term}}'": "نتایج برای «{{term}}»",
"Close Search": "بستن جستجو",
"Previous Result": "نتیجه قبلی",
"Next Result": "نتیجه بعدی"
"Next Result": "نتیجه بعدی",
"Bookmarks": "نشانک‌ها",
"Annotations": "یادداشت‌ها",
"Show Results": "نمایش نتایج"
}
@@ -839,5 +839,8 @@
"Search results for '{{term}}'": "Résultats pour « {{term}} »",
"Close Search": "Fermer la recherche",
"Previous Result": "Résultat précédent",
"Next Result": "Résultat suivant"
"Next Result": "Résultat suivant",
"Bookmarks": "Signets",
"Annotations": "Annotations",
"Show Results": "Afficher les résultats"
}
@@ -829,5 +829,8 @@
"Search results for '{{term}}'": "'{{term}}' के परिणाम",
"Close Search": "खोज बंद करें",
"Previous Result": "पिछला परिणाम",
"Next Result": "अगला परिणाम"
"Next Result": "अगला परिणाम",
"Bookmarks": "बुकमार्क",
"Annotations": "टिप्पणियाँ",
"Show Results": "परिणाम दिखाएं"
}
@@ -819,5 +819,8 @@
"Search results for '{{term}}'": "Hasil untuk '{{term}}'",
"Close Search": "Tutup pencarian",
"Previous Result": "Hasil sebelumnya",
"Next Result": "Hasil berikutnya"
"Next Result": "Hasil berikutnya",
"Bookmarks": "Penanda",
"Annotations": "Anotasi",
"Show Results": "Tampilkan Hasil"
}
@@ -839,5 +839,8 @@
"Search results for '{{term}}'": "Risultati per '{{term}}'",
"Close Search": "Chiudi ricerca",
"Previous Result": "Risultato precedente",
"Next Result": "Risultato successivo"
"Next Result": "Risultato successivo",
"Bookmarks": "Segnalibri",
"Annotations": "Annotazioni",
"Show Results": "Mostra risultati"
}
@@ -819,5 +819,8 @@
"Search results for '{{term}}'": "「{{term}}」を含む結果",
"Close Search": "検索を閉じる",
"Previous Result": "前の結果",
"Next Result": "次の結果"
"Next Result": "次の結果",
"Bookmarks": "ブックマーク",
"Annotations": "注釈",
"Show Results": "結果を表示"
}
@@ -819,5 +819,8 @@
"Search results for '{{term}}'": "'{{term}}' 검색 결과",
"Close Search": "검색 닫기",
"Previous Result": "이전 결과",
"Next Result": "다음 결과"
"Next Result": "다음 결과",
"Bookmarks": "북마크",
"Annotations": "주석",
"Show Results": "결과 표시"
}
@@ -819,5 +819,8 @@
"Search results for '{{term}}'": "Hasil untuk '{{term}}'",
"Close Search": "Tutup carian",
"Previous Result": "Hasil sebelumnya",
"Next Result": "Hasil seterusnya"
"Next Result": "Hasil seterusnya",
"Bookmarks": "Penanda buku",
"Annotations": "Anotasi",
"Show Results": "Tunjukkan Hasil"
}
@@ -829,5 +829,8 @@
"Search results for '{{term}}'": "Resultaten voor '{{term}}'",
"Close Search": "Zoekopdracht sluiten",
"Previous Result": "Vorig resultaat",
"Next Result": "Volgend resultaat"
"Next Result": "Volgend resultaat",
"Bookmarks": "Bladwijzers",
"Annotations": "Aantekeningen",
"Show Results": "Resultaten tonen"
}
@@ -849,5 +849,8 @@
"Search results for '{{term}}'": "Wyniki dla '{{term}}'",
"Close Search": "Zamknij wyszukiwanie",
"Previous Result": "Poprzedni wynik",
"Next Result": "Następny wynik"
"Next Result": "Następny wynik",
"Bookmarks": "Zakładki",
"Annotations": "Adnotacje",
"Show Results": "Pokaż wyniki"
}
@@ -839,5 +839,8 @@
"Search results for '{{term}}'": "Resultados para '{{term}}'",
"Close Search": "Fechar pesquisa",
"Previous Result": "Resultado anterior",
"Next Result": "Próximo resultado"
"Next Result": "Próximo resultado",
"Bookmarks": "Marcadores",
"Annotations": "Anotações",
"Show Results": "Mostrar resultados"
}
@@ -849,5 +849,8 @@
"Search results for '{{term}}'": "Результаты для «{{term}}»",
"Close Search": "Закрыть поиск",
"Previous Result": "Предыдущий результат",
"Next Result": "Следующий результат"
"Next Result": "Следующий результат",
"Bookmarks": "Закладки",
"Annotations": "Аннотации",
"Show Results": "Показать результаты"
}
@@ -829,5 +829,8 @@
"Search results for '{{term}}'": "'{{term}}' සඳහා ප්‍රතිඵල",
"Close Search": "සෙවුම වසන්න",
"Previous Result": "පෙර ප්‍රතිඵලය",
"Next Result": "ඊළඟ ප්‍රතිඵලය"
"Next Result": "ඊළඟ ප්‍රතිඵලය",
"Bookmarks": "පොත් සලකුණු",
"Annotations": "අනුසටහන්",
"Show Results": "ප්‍රතිඵල පෙන්වන්න"
}
@@ -829,5 +829,8 @@
"Search results for '{{term}}'": "Resultat för '{{term}}'",
"Close Search": "Stäng sökning",
"Previous Result": "Föregående resultat",
"Next Result": "Nästa resultat"
"Next Result": "Nästa resultat",
"Bookmarks": "Bokmärken",
"Annotations": "Anteckningar",
"Show Results": "Visa resultat"
}
@@ -829,5 +829,8 @@
"Search results for '{{term}}'": "'{{term}}' க்கான முடிவுகள்",
"Close Search": "தேடலை மூடு",
"Previous Result": "முந்தைய முடிவு",
"Next Result": "அடுத்த முடிவு"
"Next Result": "அடுத்த முடிவு",
"Bookmarks": "புக்மார்க்குகள்",
"Annotations": "சிறுகுறிப்புகள்",
"Show Results": "முடிவுகளைக் காட்டு"
}
@@ -819,5 +819,8 @@
"Search results for '{{term}}'": "ผลลัพธ์สำหรับ '{{term}}'",
"Close Search": "ปิดการค้นหา",
"Previous Result": "ผลลัพธ์ก่อนหน้า",
"Next Result": "ผลลัพธ์ถัดไป"
"Next Result": "ผลลัพธ์ถัดไป",
"Bookmarks": "บุ๊กมาร์ก",
"Annotations": "คำอธิบายประกอบ",
"Show Results": "แสดงผลลัพธ์"
}
@@ -829,5 +829,8 @@
"Search results for '{{term}}'": "'{{term}}' için sonuçlar",
"Close Search": "Aramayı kapat",
"Previous Result": "Önceki sonuç",
"Next Result": "Sonraki sonuç"
"Next Result": "Sonraki sonuç",
"Bookmarks": "Yer İşaretleri",
"Annotations": "Açıklamalar",
"Show Results": "Sonuçları Göster"
}
@@ -849,5 +849,8 @@
"Search results for '{{term}}'": "Результати для «{{term}}»",
"Close Search": "Закрити пошук",
"Previous Result": "Попередній результат",
"Next Result": "Наступний результат"
"Next Result": "Наступний результат",
"Bookmarks": "Закладки",
"Annotations": "Анотації",
"Show Results": "Показати результати"
}
@@ -819,5 +819,8 @@
"Search results for '{{term}}'": "Kết quả cho '{{term}}'",
"Close Search": "Đóng tìm kiếm",
"Previous Result": "Kết quả trước",
"Next Result": "Kết quả tiếp theo"
"Next Result": "Kết quả tiếp theo",
"Bookmarks": "Dấu trang",
"Annotations": "Chú thích",
"Show Results": "Hiển thị kết quả"
}
@@ -816,8 +816,11 @@
"Please log in to continue": "请登录以继续",
"Cloud File Transfers": "云文件传输",
"Show Search Results": "显示搜索结果",
"Search results for '{{term}}'": "包含"{{term}}"的结果",
"Search results for '{{term}}'": "包含\"{{term}}\"的结果",
"Close Search": "关闭搜索",
"Previous Result": "上一个结果",
"Next Result": "下一个结果"
"Next Result": "下一个结果",
"Bookmarks": "书签",
"Annotations": "注释",
"Show Results": "显示结果"
}
@@ -819,5 +819,8 @@
"Search results for '{{term}}'": "包含「{{term}}」的結果",
"Close Search": "關閉搜尋",
"Previous Result": "上一個結果",
"Next Result": "下一個結果"
"Next Result": "下一個結果",
"Bookmarks": "書籤",
"Annotations": "註解",
"Show Results": "顯示結果"
}
@@ -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(() => {
+126 -18
View File
@@ -1,15 +1,28 @@
import { create } from 'zustand';
import { BookSearchResult } from '@/types/book';
import { BookNote, BookNoteType, BookSearchResult } from '@/types/book';
// Per-book search navigation state
interface SearchNavState {
searchTerm: string;
searchResults: BookSearchResult[] | null;
searchResultIndex: number;
}
// Per-book booknotes navigation state
interface BooknotesNavState {
activeBooknoteType: BookNoteType | null;
booknoteResults: BookNote[] | null;
booknoteIndex: number;
}
interface SidebarState {
sideBarBookKey: string | null;
sideBarWidth: string;
isSideBarVisible: boolean;
isSideBarPinned: boolean;
// Search state
searchTerm: string;
searchResults: BookSearchResult[] | null;
searchResultIndex: number;
// Per-book navigation states
searchNavStates: Record<string, SearchNavState>;
booknotesNavStates: Record<string, BooknotesNavState>;
getIsSideBarVisible: () => boolean;
getSideBarWidth: () => string;
setSideBarBookKey: (key: string) => void;
@@ -18,22 +31,40 @@ interface SidebarState {
toggleSideBarPin: () => void;
setSideBarVisible: (visible: boolean) => void;
setSideBarPin: (pinned: boolean) => void;
// Search actions
setSearchTerm: (term: string) => void;
setSearchResults: (results: BookSearchResult[] | null) => void;
setSearchResultIndex: (index: number) => void;
clearSearch: () => void;
// Search actions (per bookKey)
getSearchNavState: (bookKey: string) => SearchNavState;
setSearchTerm: (bookKey: string, term: string) => void;
setSearchResults: (bookKey: string, results: BookSearchResult[] | null) => void;
setSearchResultIndex: (bookKey: string, index: number) => void;
clearSearch: (bookKey: string) => void;
// Booknotes navigation actions (per bookKey)
getBooknotesNavState: (bookKey: string) => BooknotesNavState;
setActiveBooknoteType: (bookKey: string, type: BookNoteType | null) => void;
setBooknoteResults: (bookKey: string, results: BookNote[] | null) => void;
setBooknoteIndex: (bookKey: string, index: number) => void;
clearBooknotesNav: (bookKey: string) => void;
}
const defaultSearchNavState: SearchNavState = {
searchTerm: '',
searchResults: null,
searchResultIndex: 0,
};
const defaultBooknotesNavState: BooknotesNavState = {
activeBooknoteType: null,
booknoteResults: null,
booknoteIndex: 0,
};
export const useSidebarStore = create<SidebarState>((set, get) => ({
sideBarBookKey: null,
sideBarWidth: '',
isSideBarVisible: false,
isSideBarPinned: false,
// Search state
searchTerm: '',
searchResults: null,
searchResultIndex: 0,
// Per-book navigation states
searchNavStates: {},
booknotesNavStates: {},
getIsSideBarVisible: () => get().isSideBarVisible,
getSideBarWidth: () => get().sideBarWidth,
setSideBarBookKey: (key: string) => set({ sideBarBookKey: key }),
@@ -43,8 +74,85 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
setSideBarVisible: (visible: boolean) => set({ isSideBarVisible: visible }),
setSideBarPin: (pinned: boolean) => set({ isSideBarPinned: pinned }),
// Search actions
setSearchTerm: (term: string) => set({ searchTerm: term }),
setSearchResults: (results: BookSearchResult[] | null) => set({ searchResults: results }),
setSearchResultIndex: (index: number) => set({ searchResultIndex: index }),
clearSearch: () => set({ searchTerm: '', searchResults: null, searchResultIndex: 0 }),
getSearchNavState: (bookKey: string) => {
return get().searchNavStates[bookKey] || defaultSearchNavState;
},
setSearchTerm: (bookKey: string, term: string) =>
set((state) => ({
searchNavStates: {
...state.searchNavStates,
[bookKey]: {
...(state.searchNavStates[bookKey] || defaultSearchNavState),
searchTerm: term,
},
},
})),
setSearchResults: (bookKey: string, results: BookSearchResult[] | null) =>
set((state) => ({
searchNavStates: {
...state.searchNavStates,
[bookKey]: {
...(state.searchNavStates[bookKey] || defaultSearchNavState),
searchResults: results,
},
},
})),
setSearchResultIndex: (bookKey: string, index: number) =>
set((state) => ({
searchNavStates: {
...state.searchNavStates,
[bookKey]: {
...(state.searchNavStates[bookKey] || defaultSearchNavState),
searchResultIndex: index,
},
},
})),
clearSearch: (bookKey: string) =>
set((state) => ({
searchNavStates: {
...state.searchNavStates,
[bookKey]: { ...defaultSearchNavState },
},
})),
// Booknotes navigation actions
getBooknotesNavState: (bookKey: string) => {
return get().booknotesNavStates[bookKey] || defaultBooknotesNavState;
},
setActiveBooknoteType: (bookKey: string, type: BookNoteType | null) =>
set((state) => ({
booknotesNavStates: {
...state.booknotesNavStates,
[bookKey]: {
...(state.booknotesNavStates[bookKey] || defaultBooknotesNavState),
activeBooknoteType: type,
},
},
})),
setBooknoteResults: (bookKey: string, results: BookNote[] | null) =>
set((state) => ({
booknotesNavStates: {
...state.booknotesNavStates,
[bookKey]: {
...(state.booknotesNavStates[bookKey] || defaultBooknotesNavState),
booknoteResults: results,
},
},
})),
setBooknoteIndex: (bookKey: string, index: number) =>
set((state) => ({
booknotesNavStates: {
...state.booknotesNavStates,
[bookKey]: {
...(state.booknotesNavStates[bookKey] || defaultBooknotesNavState),
booknoteIndex: index,
},
},
})),
clearBooknotesNav: (bookKey: string) =>
set((state) => ({
booknotesNavStates: {
...state.booknotesNavStates,
[bookKey]: { ...defaultBooknotesNavState },
},
})),
}));
+8 -2
View File
@@ -24,12 +24,13 @@ export const findTocItemBS = (toc: TOCItem[], cfi: string): TOCItem | null => {
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const item = toc[mid]!;
const currentCfi = toc[mid]!.cfi || '';
const comparison = CFI.compare(currentCfi, cfi);
if (comparison === 0) {
return toc[mid]!;
return findInSubitems(item, cfi) ?? item;
} else if (comparison < 0) {
result = toc[mid]!;
result = findInSubitems(item, cfi) ?? item;
left = mid + 1;
} else {
right = mid - 1;
@@ -39,6 +40,11 @@ export const findTocItemBS = (toc: TOCItem[], cfi: string): TOCItem | null => {
return result;
};
const findInSubitems = (item: TOCItem, cfi: string): TOCItem | null => {
if (!item.subitems?.length) return null;
return findTocItemBS(item.subitems, cfi);
};
export const updateToc = async (
bookDoc: BookDoc,
sortedTOC: boolean,