feat(search): add search results navigation bar, closes #1183 (#2842)

Add a floating navigation component that appears when search results exist.
The component includes:
   - Top bar: displays search term and current section with TOC and close buttons
   - Bottom bar: search results, previous, and next navigation buttons
   - Page-based navigation using isCfiInLocation to skip between pages with results
This commit is contained in:
Huang Xin
2026-01-03 08:10:23 +01:00
committed by GitHub
parent 730ee21651
commit a5e09e8454
39 changed files with 514 additions and 89 deletions
@@ -864,5 +864,10 @@
"Book not found in library": "الكتاب غير موجود في المكتبة",
"Unknown error": "خطأ غير معروف",
"Please log in to continue": "يرجى تسجيل الدخول للمتابعة",
"Cloud File Transfers": "نقل الملفات السحابية"
"Cloud File Transfers": "نقل الملفات السحابية",
"Show Search Results": "عرض نتائج البحث",
"Search results for '{{term}}'": "نتائج البحث عن «{{term}}»",
"Close Search": "إغلاق البحث",
"Previous Result": "النتيجة السابقة",
"Next Result": "النتيجة التالية"
}
@@ -824,5 +824,10 @@
"Book not found in library": "লাইব্রেরিতে বই পাওয়া যায়নি",
"Unknown error": "অজানা ত্রুটি",
"Please log in to continue": "চালিয়ে যেতে লগইন করুন",
"Cloud File Transfers": "ক্লাউড ফাইল স্থানান্তরসমূহ"
"Cloud File Transfers": "ক্লাউড ফাইল স্থানান্তরসমূহ",
"Show Search Results": "অনুসন্ধান ফলাফল দেখান",
"Search results for '{{term}}'": "'{{term}}' এর ফলাফল",
"Close Search": "অনুসন্ধান বন্ধ করুন",
"Previous Result": "আগের ফলাফল",
"Next Result": "পরবর্তী ফলাফল"
}
@@ -814,5 +814,10 @@
"Book not found in library": "དཔེ་མཛོད་ནང་དཔེ་ཆ་རྙེད་མ་སོང་",
"Unknown error": "མ་ཤེས་པའི་ནོར་འཁྲུལ་",
"Please log in to continue": "མུ་མཐུད་པར་ནང་འཛུལ་བྱོས་",
"Cloud File Transfers": "སྤྲིན་གནས་ཡིག་ཆ་སྐྱེལ་འདྲེན།"
"Cloud File Transfers": "སྤྲིན་གནས་ཡིག་ཆ་སྐྱེལ་འདྲེན།",
"Show Search Results": "འཚོལ་བཤེར་འབྲས་བུ་སྟོན།",
"Search results for '{{term}}'": "'{{term}}'ཡི་འབྲས་བུ།",
"Close Search": "འཚོལ་བཤེར་སྒོ་རྒྱག",
"Previous Result": "སྔོན་མའི་འབྲས་བུ།",
"Next Result": "རྗེས་མའི་འབྲས་བུ།"
}
@@ -824,5 +824,10 @@
"Book not found in library": "Buch nicht in der Bibliothek gefunden",
"Unknown error": "Unbekannter Fehler",
"Please log in to continue": "Bitte melden Sie sich an, um fortzufahren",
"Cloud File Transfers": "Cloud-Dateiübertragungen"
"Cloud File Transfers": "Cloud-Dateiübertragungen",
"Show Search Results": "Suchergebnisse anzeigen",
"Search results for '{{term}}'": "Ergebnisse für '{{term}}'",
"Close Search": "Suche schließen",
"Previous Result": "Vorheriges Ergebnis",
"Next Result": "Nächstes Ergebnis"
}
@@ -824,5 +824,10 @@
"Book not found in library": "Το βιβλίο δεν βρέθηκε στη βιβλιοθήκη",
"Unknown error": "Άγνωστο σφάλμα",
"Please log in to continue": "Παρακαλώ συνδεθείτε για να συνεχίσετε",
"Cloud File Transfers": "Μεταφορές αρχείων στο cloud"
"Cloud File Transfers": "Μεταφορές αρχείων στο cloud",
"Show Search Results": "Εμφάνιση αποτελεσμάτων",
"Search results for '{{term}}'": "Αποτελέσματα για '{{term}}'",
"Close Search": "Κλείσιμο αναζήτησης",
"Previous Result": "Προηγούμενο αποτέλεσμα",
"Next Result": "Επόμενο αποτέλεσμα"
}
@@ -834,5 +834,10 @@
"Book not found in library": "Libro no encontrado en la biblioteca",
"Unknown error": "Error desconocido",
"Please log in to continue": "Inicia sesión para continuar",
"Cloud File Transfers": "Transferencias en la nube"
"Cloud File Transfers": "Transferencias en la nube",
"Show Search Results": "Mostrar resultados",
"Search results for '{{term}}'": "Resultados para '{{term}}'",
"Close Search": "Cerrar búsqueda",
"Previous Result": "Resultado anterior",
"Next Result": "Resultado siguiente"
}
@@ -824,5 +824,10 @@
"Book not found in library": "کتاب در کتابخانه یافت نشد",
"Unknown error": "خطای ناشناخته",
"Please log in to continue": "لطفاً برای ادامه وارد شوید",
"Cloud File Transfers": "انتقال فایل‌های ابری"
"Cloud File Transfers": "انتقال فایل‌های ابری",
"Show Search Results": "نمایش نتایج جستجو",
"Search results for '{{term}}'": "نتایج برای «{{term}}»",
"Close Search": "بستن جستجو",
"Previous Result": "نتیجه قبلی",
"Next Result": "نتیجه بعدی"
}
@@ -834,5 +834,10 @@
"Book not found in library": "Livre non trouvé dans la bibliothèque",
"Unknown error": "Erreur inconnue",
"Please log in to continue": "Veuillez vous connecter pour continuer",
"Cloud File Transfers": "Transferts de fichiers cloud"
"Cloud File Transfers": "Transferts de fichiers cloud",
"Show Search Results": "Afficher les résultats",
"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"
}
@@ -824,5 +824,10 @@
"Book not found in library": "पुस्तकालय में पुस्तक नहीं मिली",
"Unknown error": "अज्ञात त्रुटि",
"Please log in to continue": "जारी रखने के लिए लॉगिन करें",
"Cloud File Transfers": "क्लाउड फ़ाइल स्थानांतरण"
"Cloud File Transfers": "क्लाउड फ़ाइल स्थानांतरण",
"Show Search Results": "खोज परिणाम दिखाएं",
"Search results for '{{term}}'": "'{{term}}' के परिणाम",
"Close Search": "खोज बंद करें",
"Previous Result": "पिछला परिणाम",
"Next Result": "अगला परिणाम"
}
@@ -814,5 +814,10 @@
"Book not found in library": "Buku tidak ditemukan di perpustakaan",
"Unknown error": "Kesalahan tidak diketahui",
"Please log in to continue": "Silakan masuk untuk melanjutkan",
"Cloud File Transfers": "Transfer File Cloud"
"Cloud File Transfers": "Transfer File Cloud",
"Show Search Results": "Tampilkan hasil pencarian",
"Search results for '{{term}}'": "Hasil untuk '{{term}}'",
"Close Search": "Tutup pencarian",
"Previous Result": "Hasil sebelumnya",
"Next Result": "Hasil berikutnya"
}
@@ -834,5 +834,10 @@
"Book not found in library": "Libro non trovato nella libreria",
"Unknown error": "Errore sconosciuto",
"Please log in to continue": "Accedi per continuare",
"Cloud File Transfers": "Trasferimenti file cloud"
"Cloud File Transfers": "Trasferimenti file cloud",
"Show Search Results": "Mostra risultati",
"Search results for '{{term}}'": "Risultati per '{{term}}'",
"Close Search": "Chiudi ricerca",
"Previous Result": "Risultato precedente",
"Next Result": "Risultato successivo"
}
@@ -814,5 +814,10 @@
"Book not found in library": "ライブラリに本が見つかりません",
"Unknown error": "不明なエラー",
"Please log in to continue": "続行するにはログインしてください",
"Cloud File Transfers": "クラウドファイル転送"
"Cloud File Transfers": "クラウドファイル転送",
"Show Search Results": "検索結果を表示",
"Search results for '{{term}}'": "「{{term}}」を含む結果",
"Close Search": "検索を閉じる",
"Previous Result": "前の結果",
"Next Result": "次の結果"
}
@@ -814,5 +814,10 @@
"Book not found in library": "라이브러리에서 책을 찾을 수 없음",
"Unknown error": "알 수 없는 오류",
"Please log in to continue": "계속하려면 로그인하세요",
"Cloud File Transfers": "클라우드 파일 전송"
"Cloud File Transfers": "클라우드 파일 전송",
"Show Search Results": "검색 결과 보기",
"Search results for '{{term}}'": "'{{term}}' 검색 결과",
"Close Search": "검색 닫기",
"Previous Result": "이전 결과",
"Next Result": "다음 결과"
}
@@ -814,5 +814,10 @@
"Book not found in library": "Buku tidak dijumpai dalam perpustakaan",
"Unknown error": "Ralat tidak diketahui",
"Please log in to continue": "Sila log masuk untuk meneruskan",
"Cloud File Transfers": "Pemindahan Fail Awan"
"Cloud File Transfers": "Pemindahan Fail Awan",
"Show Search Results": "Tunjukkan hasil carian",
"Search results for '{{term}}'": "Hasil untuk '{{term}}'",
"Close Search": "Tutup carian",
"Previous Result": "Hasil sebelumnya",
"Next Result": "Hasil seterusnya"
}
@@ -824,5 +824,10 @@
"Book not found in library": "Boek niet gevonden in bibliotheek",
"Unknown error": "Onbekende fout",
"Please log in to continue": "Log in om door te gaan",
"Cloud File Transfers": "Cloud Bestandoverdrachten"
"Cloud File Transfers": "Cloud Bestandoverdrachten",
"Show Search Results": "Zoekresultaten tonen",
"Search results for '{{term}}'": "Resultaten voor '{{term}}'",
"Close Search": "Zoekopdracht sluiten",
"Previous Result": "Vorig resultaat",
"Next Result": "Volgend resultaat"
}
@@ -844,5 +844,10 @@
"Book not found in library": "Książka nie została znaleziona w bibliotece",
"Unknown error": "Nieznany błąd",
"Please log in to continue": "Zaloguj się, aby kontynuować",
"Cloud File Transfers": "Transfery plików w chmurze"
"Cloud File Transfers": "Transfery plików w chmurze",
"Show Search Results": "Pokaż wyniki wyszukiwania",
"Search results for '{{term}}'": "Wyniki dla '{{term}}'",
"Close Search": "Zamknij wyszukiwanie",
"Previous Result": "Poprzedni wynik",
"Next Result": "Następny wynik"
}
@@ -834,5 +834,10 @@
"Book not found in library": "Livro não encontrado na biblioteca",
"Unknown error": "Erro desconhecido",
"Please log in to continue": "Faça login para continuar",
"Cloud File Transfers": "Transferências de Arquivos"
"Cloud File Transfers": "Transferências de Arquivos",
"Show Search Results": "Mostrar resultados",
"Search results for '{{term}}'": "Resultados para '{{term}}'",
"Close Search": "Fechar pesquisa",
"Previous Result": "Resultado anterior",
"Next Result": "Próximo resultado"
}
@@ -844,5 +844,10 @@
"Book not found in library": "Книга не найдена в библиотеке",
"Unknown error": "Неизвестная ошибка",
"Please log in to continue": "Войдите, чтобы продолжить",
"Cloud File Transfers": "Передача файлов в облако"
"Cloud File Transfers": "Передача файлов в облако",
"Show Search Results": "Показать результаты",
"Search results for '{{term}}'": "Результаты для «{{term}}»",
"Close Search": "Закрыть поиск",
"Previous Result": "Предыдущий результат",
"Next Result": "Следующий результат"
}
@@ -824,5 +824,10 @@
"Book not found in library": "පුස්තකාලයේ පොත හමු නොවීය",
"Unknown error": "නොදන්නා දෝෂයක්",
"Please log in to continue": "ඉදිරියට යාමට පිවිසෙන්න",
"Cloud File Transfers": "කලාප ගොනු හුවමාරු"
"Cloud File Transfers": "කලාප ගොනු හුවමාරු",
"Show Search Results": "සෙවුම් ප්‍රතිඵල පෙන්වන්න",
"Search results for '{{term}}'": "'{{term}}' සඳහා ප්‍රතිඵල",
"Close Search": "සෙවුම වසන්න",
"Previous Result": "පෙර ප්‍රතිඵලය",
"Next Result": "ඊළඟ ප්‍රතිඵලය"
}
@@ -824,5 +824,10 @@
"Book not found in library": "Boken hittades inte i biblioteket",
"Unknown error": "Okänt fel",
"Please log in to continue": "Logga in för att fortsätta",
"Cloud File Transfers": "Molnfilöverföringar"
"Cloud File Transfers": "Molnfilöverföringar",
"Show Search Results": "Visa sökresultat",
"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"
}
@@ -824,5 +824,10 @@
"Book not found in library": "நூலகத்தில் புத்தகம் கிடைக்கவில்லை",
"Unknown error": "தெரியாத பிழை",
"Please log in to continue": "தொடர உள்நுழையவும்",
"Cloud File Transfers": "மேகக் கோப்பு பரிமாற்றங்கள்"
"Cloud File Transfers": "மேகக் கோப்பு பரிமாற்றங்கள்",
"Show Search Results": "தேடல் முடிவுகளைக் காட்டு",
"Search results for '{{term}}'": "'{{term}}' க்கான முடிவுகள்",
"Close Search": "தேடலை மூடு",
"Previous Result": "முந்தைய முடிவு",
"Next Result": "அடுத்த முடிவு"
}
@@ -814,5 +814,10 @@
"Book not found in library": "ไม่พบหนังสือในห้องสมุด",
"Unknown error": "ข้อผิดพลาดที่ไม่รู้จัก",
"Please log in to continue": "กรุณาเข้าสู่ระบบเพื่อดำเนินการต่อ",
"Cloud File Transfers": "การถ่ายโอนไฟล์คลาวด์"
"Cloud File Transfers": "การถ่ายโอนไฟล์คลาวด์",
"Show Search Results": "แสดงผลการค้นหา",
"Search results for '{{term}}'": "ผลลัพธ์สำหรับ '{{term}}'",
"Close Search": "ปิดการค้นหา",
"Previous Result": "ผลลัพธ์ก่อนหน้า",
"Next Result": "ผลลัพธ์ถัดไป"
}
@@ -824,5 +824,10 @@
"Book not found in library": "Kitap kütüphanede bulunamadı",
"Unknown error": "Bilinmeyen hata",
"Please log in to continue": "Devam etmek için giriş yapın",
"Cloud File Transfers": "Bulut Dosya Transferleri"
"Cloud File Transfers": "Bulut Dosya Transferleri",
"Show Search Results": "Arama sonuçlarını göster",
"Search results for '{{term}}'": "'{{term}}' için sonuçlar",
"Close Search": "Aramayı kapat",
"Previous Result": "Önceki sonuç",
"Next Result": "Sonraki sonuç"
}
@@ -844,5 +844,10 @@
"Book not found in library": "Книгу не знайдено в бібліотеці",
"Unknown error": "Невідома помилка",
"Please log in to continue": "Увійдіть, щоб продовжити",
"Cloud File Transfers": "Передача файлів у хмару"
"Cloud File Transfers": "Передача файлів у хмару",
"Show Search Results": "Показати результати",
"Search results for '{{term}}'": "Результати для «{{term}}»",
"Close Search": "Закрити пошук",
"Previous Result": "Попередній результат",
"Next Result": "Наступний результат"
}
@@ -814,5 +814,10 @@
"Book not found in library": "Không tìm thấy sách trong thư viện",
"Unknown error": "Lỗi không xác định",
"Please log in to continue": "Vui lòng đăng nhập để tiếp tục",
"Cloud File Transfers": "Truyền tệp đám mây"
"Cloud File Transfers": "Truyền tệp đám mây",
"Show Search Results": "Hiển thị kết quả tìm kiếm",
"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"
}
@@ -814,5 +814,10 @@
"Book not found in library": "在书库中找不到书籍",
"Unknown error": "未知错误",
"Please log in to continue": "请登录以继续",
"Cloud File Transfers": "云文件传输"
"Cloud File Transfers": "云文件传输",
"Show Search Results": "显示搜索结果",
"Search results for '{{term}}'": "包含"{{term}}"的结果",
"Close Search": "关闭搜索",
"Previous Result": "上一个结果",
"Next Result": "下一个结果"
}
@@ -814,5 +814,10 @@
"Book not found in library": "在書庫中找不到書籍",
"Unknown error": "未知錯誤",
"Please log in to continue": "請登入以繼續",
"Cloud File Transfers": "雲端檔案傳輸"
"Cloud File Transfers": "雲端檔案傳輸",
"Show Search Results": "顯示搜尋結果",
"Search results for '{{term}}'": "包含「{{term}}」的結果",
"Close Search": "關閉搜尋",
"Previous Result": "上一個結果",
"Next Result": "下一個結果"
}
@@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react';
import { MdOutlineBookmarkAdd, MdOutlineBookmark } from 'react-icons/md';
import * as CFI from 'foliate-js/epubcfi.js';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
@@ -12,6 +11,7 @@ import { uniqueId } from '@/utils/misc';
import Button from '@/components/Button';
import { getCurrentPage } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { isCfiInLocation } from '@/utils/cfi';
interface BookmarkTogglerProps {
bookKey: string;
@@ -66,13 +66,8 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
}
} else {
setIsBookmarked(false);
const start = CFI.collapse(cfi);
const end = CFI.collapse(cfi, true);
bookmarks.forEach((item) => {
if (
item.type === 'bookmark' &&
CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) <= 0
) {
if (item.type === 'bookmark' && isCfiInLocation(item.cfi, cfi)) {
item.deletedAt = Date.now();
}
});
@@ -101,11 +96,9 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
const { location: cfi } = progress || {};
if (!cfi) return;
const start = CFI.collapse(cfi);
const end = CFI.collapse(cfi, true);
const locationBookmarked = booknotes
.filter((booknote) => booknote.type === 'bookmark' && !booknote.deletedAt)
.some((item) => CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) <= 0);
.some((item) => isCfiInLocation(item.cfi, cfi));
setIsBookmarked(locationBookmarked);
setBookmarkRibbonVisibility(bookKey, locationBookmarked);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -11,6 +11,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { getGridTemplate, getInsetEdges } from '@/utils/grid';
import { getViewInsets } from '@/utils/insets';
import SettingsDialog from '@/components/settings/SettingsDialog';
import SearchResultsNav from './sidebar/SearchResultsNav';
import FoliateViewer from './FoliateViewer';
import SectionInfo from './SectionInfo';
import HeaderBar from './HeaderBar';
@@ -198,6 +199,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
/>
)}
<Annotator bookKey={bookKey} />
<SearchResultsNav bookKey={bookKey} gridInsets={gridInsets} />
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
<FooterBar
bookKey={bookKey}
@@ -50,7 +50,13 @@ export const annotationToolButtons = createAnnotationToolButtons([
tooltip: _('Annotate text after selection'),
Icon: BsPencilSquare,
},
{ type: 'search', label: _('Search'), tooltip: _('Search text after selection'), Icon: FiSearch },
{
type: 'search',
label: _('Search'),
tooltip: _('Search text after selection'),
Icon: FiSearch,
quickAction: true,
},
{
type: 'dictionary',
label: _('Dictionary'),
@@ -24,6 +24,7 @@ import { findTocItemBS } from '@/utils/toc';
import { throttle } from '@/utils/throttle';
import { runSimpleCC } from '@/utils/simplecc';
import { getWordCount } from '@/utils/word';
import { isCfiInLocation } from '@/utils/cfi';
import { getHighlightColorHex } from '../../utils/annotatorUtil';
import { annotationToolButtons } from './AnnotationTools';
import AnnotationPopup from './AnnotationPopup';
@@ -386,6 +387,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
handleDismissPopupAndSelection();
}, 0);
break;
case 'search':
handleSearch();
break;
case 'dictionary':
handleDictionary();
break;
@@ -460,16 +464,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
useEffect(() => {
if (!progress) return;
const { location } = progress;
const start = CFI.collapse(location);
const end = CFI.collapse(location, true);
const { booknotes = [] } = config;
const annotations = booknotes.filter(
(item) =>
!item.deletedAt &&
item.type === 'annotation' &&
item.style &&
CFI.compare(item.cfi, start) >= 0 &&
CFI.compare(item.cfi, end) <= 0,
isCfiInLocation(item.cfi, location),
);
const notes = booknotes.filter(
(item) =>
@@ -477,8 +478,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
item.type === 'annotation' &&
item.note &&
item.note.trim().length > 0 &&
CFI.compare(item.cfi, start) >= 0 &&
CFI.compare(item.cfi, end) <= 0,
isCfiInLocation(item.cfi, location),
);
try {
Promise.all(annotations.map((annotation) => view?.addAnnotation(annotation)));
@@ -1,11 +1,12 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { FaSearch, FaChevronDown } from 'react-icons/fa';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { BookSearchConfig, BookSearchResult } from '@/types/book';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
@@ -21,25 +22,17 @@ const MINIMUM_SEARCH_TERM_LENGTH_CJK = 1;
interface SearchBarProps {
isVisible: boolean;
bookKey: string;
searchTerm: string;
onSearchResultChange: (results: BookSearchResult[]) => void;
onHideSearchBar: () => void;
}
const SearchBar: React.FC<SearchBarProps> = ({
isVisible,
bookKey,
searchTerm: term,
onSearchResultChange,
onHideSearchBar,
}) => {
const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchBar }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getBookData } = useBookDataStore();
const { getConfig, saveConfig } = useBookDataStore();
const { getView, getProgress } = useReaderStore();
const [searchTerm, setSearchTerm] = useState(term);
const { searchTerm, setSearchTerm, setSearchResults } = useSidebarStore();
const queuedSearchTerm = useRef('');
const inputRef = useRef<HTMLInputElement>(null);
const inputFocusedRef = useRef(false);
@@ -59,12 +52,6 @@ const SearchBar: React.FC<SearchBarProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey]);
useEffect(() => {
setSearchTerm(term);
handleSearchTermChange(term);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [term]);
useEffect(() => {
if (isVisible && inputRef.current) {
inputRef.current.onblur = () => {
@@ -138,7 +125,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
for await (const result of generator) {
if (typeof result === 'string') {
if (result === 'done') {
onSearchResultChange([...results]);
setSearchResults([...results]);
console.log('search done');
}
} else {
@@ -155,7 +142,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
}
} else {
results.push(result);
onSearchResultChange([...results]);
setSearchResults([...results]);
}
}
@@ -166,14 +153,13 @@ const SearchBar: React.FC<SearchBarProps> = ({
processResults();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[progress, searchConfig],
[progress, searchConfig, setSearchResults],
);
const resetSearch = useCallback(() => {
onSearchResultChange([]);
setSearchResults([]);
view?.clearSearch();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
}, [view, setSearchResults]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSearchTermChange = useCallback(
@@ -0,0 +1,153 @@
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';
interface SearchResultsNavProps {
bookKey: string;
gridInsets: Insets;
}
const SearchResultsNav: React.FC<SearchResultsNavProps> = ({ bookKey, gridInsets }) => {
const {
searchTerm,
currentSection,
showSearchNav,
hasPreviousPage,
hasNextPage,
handleShowResults,
handleCloseSearch,
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>
);
};
export default SearchResultsNav;
// Helper function to flatten search results into a single array of matches with section labels
export function flattenSearchResults(
results: BookSearchResult[] | BookSearchMatch[],
): { cfi: string; sectionLabel: string }[] {
const flattened: { cfi: string; sectionLabel: string }[] = [];
for (const result of results) {
if ('subitems' in result) {
// BookSearchResult with subitems
for (const item of result.subitems) {
flattened.push({ cfi: item.cfi, sectionLabel: result.label });
}
} else {
// BookSearchMatch
flattened.push({ cfi: result.cfi, sectionLabel: '' });
}
}
return flattened;
}
// Helper function to find the index of current result based on CFI
export function findCurrentResultIndex(
flattenedResults: { cfi: string; sectionLabel: string }[],
currentLocation: string | undefined,
): number {
if (!currentLocation || flattenedResults.length === 0) return 0;
// Try to find exact match or closest match
for (let i = 0; i < flattenedResults.length; i++) {
if (flattenedResults[i]!.cfi === currentLocation) {
return i;
}
}
return 0;
}
@@ -7,7 +7,6 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { BookSearchResult } from '@/types/book';
import { eventDispatcher } from '@/utils/event';
import { getBookDirFromLanguage } from '@/utils/book';
import { useEnv } from '@/context/EnvContext';
@@ -34,12 +33,11 @@ const SideBar: React.FC<{
const { appService } = useEnv();
const { updateAppTheme, safeAreaInsets } = useThemeStore();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
const { sideBarBookKey, searchTerm, searchResults, setSearchTerm, clearSearch } =
useSidebarStore();
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
const [searchResults, setSearchResults] = useState<BookSearchResult[] | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const searchTermRef = useRef(searchTerm);
const sidebarHeight = useRef(1.0);
const isMobile = window.innerWidth < 640;
@@ -193,13 +191,12 @@ const SideBar: React.FC<{
const handleHideSearchBar = useCallback(() => {
setIsSearchBarVisible(false);
setSearchResults(null);
setTimeout(() => {
setSearchTerm('');
clearSearch();
}, 100);
getView(sideBarBookKey)?.clearSearch();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sideBarBookKey]);
}, [sideBarBookKey, clearSearch]);
const handleHideSideBar = useCallback(() => {
if (searchTermRef.current) {
@@ -317,8 +314,6 @@ const SideBar: React.FC<{
<SearchBar
isVisible={isSearchBarVisible}
bookKey={sideBarBookKey!}
searchTerm={searchTerm}
onSearchResultChange={setSearchResults}
onHideSearchBar={handleHideSearchBar}
/>
</div>
@@ -14,11 +14,11 @@ import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { parseSSMLLang } from '@/utils/ssml';
import { throttle } from '@/utils/throttle';
import { CFI } from '@/libs/document';
import { Insets } from '@/types/misc';
import { Overlay } from '@/components/Overlay';
import { fetchImageAsBase64 } from '@/utils/image';
import { invokeUseBackgroundAudio } from '@/utils/bridge';
import { isCfiInLocation } from '@/utils/cfi';
import { getLocale } from '@/utils/misc';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
@@ -306,9 +306,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
if (!ttsFromRange && viewSettings.ttsLocation) {
const { location } = progress;
const ttsCfi = viewSettings.ttsLocation;
const start = CFI.collapse(location);
const end = CFI.collapse(location, true);
if (CFI.compare(start, ttsCfi) * CFI.compare(end, ttsCfi) <= 0) {
if (isCfiInLocation(ttsCfi, location)) {
const { index, anchor } = view.resolveCFI(ttsCfi);
const { doc } = view.renderer.getContents().find((x) => x.index === index) || {};
if (doc) {
@@ -1,18 +1,11 @@
import { useEffect, useMemo, useRef } from 'react';
import { BookProgress } from '@/types/book';
import * as CFI from 'foliate-js/epubcfi.js';
import { isCfiInLocation } from '@/utils/cfi';
const useScrollToItem = (cfi: string, progress: BookProgress | null) => {
const viewRef = useRef<HTMLLIElement | null>(null);
const isCurrent = useMemo(() => {
if (!progress) return false;
const { location } = progress;
const start = CFI.collapse(location);
const end = CFI.collapse(location, true);
return CFI.compare(cfi, start) >= 0 && CFI.compare(cfi, end) <= 0;
}, [cfi, progress]);
const isCurrent = useMemo(() => isCfiInLocation(cfi, progress?.location), [cfi, progress]);
useEffect(() => {
if (!viewRef.current || !isCurrent) return;
@@ -0,0 +1,135 @@
import { useCallback, useMemo } from 'react';
import { useSidebarStore } from '@/store/sidebarStore';
import { useReaderStore } from '@/store/readerStore';
import { isCfiInLocation } from '@/utils/cfi';
import { flattenSearchResults } from '../components/sidebar/SearchResultsNav';
export function useSearchNav(bookKey: string) {
const { getView, getProgress } = useReaderStore();
const {
sideBarBookKey,
setSideBarVisible,
searchTerm,
searchResults,
searchResultIndex,
setSearchResultIndex,
clearSearch,
} = useSidebarStore();
const progress = getProgress(bookKey);
const currentLocation = useMemo(() => {
return progress?.location;
}, [progress]);
// Flatten search results for navigation
const flattenedResults = useMemo(() => {
if (!searchResults) return [];
return flattenSearchResults(searchResults);
}, [searchResults]);
const totalResults = flattenedResults.length;
const hasSearchResults = searchResults && totalResults > 0;
const showSearchNav = hasSearchResults;
// Get current section label
const currentSection = useMemo(() => {
if (!flattenedResults.length || searchResultIndex >= flattenedResults.length) return '';
return flattenedResults[searchResultIndex]?.sectionLabel || '';
}, [flattenedResults, searchResultIndex]);
// Find results on the current page
const currentPageResults = useMemo(() => {
if (!flattenedResults.length || !currentLocation) return { firstIndex: -1, lastIndex: -1 };
let firstIndex = -1;
let lastIndex = -1;
for (let i = 0; i < flattenedResults.length; i++) {
const result = flattenedResults[i];
if (result && isCfiInLocation(result.cfi, currentLocation)) {
if (firstIndex === -1) firstIndex = i;
lastIndex = i;
}
}
if (firstIndex !== -1) {
setTimeout(() => setSearchResultIndex(firstIndex), 0);
}
return { firstIndex, lastIndex };
}, [flattenedResults, currentLocation, setSearchResultIndex]);
// Navigate to a specific search result
const navigateToResult = useCallback(
(index: number) => {
if (!sideBarBookKey || !flattenedResults.length) return;
if (index < 0 || index >= flattenedResults.length) return;
const result = flattenedResults[index];
if (result) {
setSearchResultIndex(index);
getView(sideBarBookKey)?.goTo(result.cfi);
}
},
[sideBarBookKey, flattenedResults, setSearchResultIndex, getView],
);
const handleShowResults = useCallback(() => {
setSideBarVisible(true);
}, [setSideBarVisible]);
const handleCloseSearch = useCallback(() => {
clearSearch();
if (sideBarBookKey) {
getView(sideBarBookKey)?.clearSearch();
}
}, [clearSearch, sideBarBookKey, getView]);
// Navigate to the previous page with results (last result before current page)
const handlePreviousResult = useCallback(() => {
const { firstIndex } = currentPageResults;
if (firstIndex > 0) {
// Navigate to the result just before the first result on current page
navigateToResult(firstIndex - 1);
} else if (firstIndex === -1 && searchResultIndex > 0) {
// No results on current page, just go to previous result
navigateToResult(searchResultIndex - 1);
}
}, [currentPageResults, searchResultIndex, navigateToResult]);
// Navigate to the next page with results (first result after current page)
const handleNextResult = useCallback(() => {
const { lastIndex } = currentPageResults;
if (lastIndex >= 0 && lastIndex < totalResults - 1) {
// Navigate to the result just after the last result on current page
navigateToResult(lastIndex + 1);
} else if (lastIndex === -1 && searchResultIndex < totalResults - 1) {
// No results on current page, just go to next result
navigateToResult(searchResultIndex + 1);
}
}, [currentPageResults, totalResults, searchResultIndex, navigateToResult]);
// Check if there are results before/after the current page
const hasPreviousPage =
currentPageResults.firstIndex > 0 ||
(currentPageResults.firstIndex === -1 && searchResultIndex > 0);
const hasNextPage =
(currentPageResults.lastIndex >= 0 && currentPageResults.lastIndex < totalResults - 1) ||
(currentPageResults.lastIndex === -1 && searchResultIndex < totalResults - 1);
return {
searchTerm,
currentSection,
searchResultIndex,
totalResults,
showSearchNav,
hasPreviousPage,
hasNextPage,
handleShowResults,
handleCloseSearch,
handlePreviousResult,
handleNextResult,
};
}
@@ -1,10 +1,15 @@
import { create } from 'zustand';
import { BookSearchResult } from '@/types/book';
interface SidebarState {
sideBarBookKey: string | null;
sideBarWidth: string;
isSideBarVisible: boolean;
isSideBarPinned: boolean;
// Search state
searchTerm: string;
searchResults: BookSearchResult[] | null;
searchResultIndex: number;
getIsSideBarVisible: () => boolean;
getSideBarWidth: () => string;
setSideBarBookKey: (key: string) => void;
@@ -13,6 +18,11 @@ 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;
}
export const useSidebarStore = create<SidebarState>((set, get) => ({
@@ -20,6 +30,10 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
sideBarWidth: '',
isSideBarVisible: false,
isSideBarPinned: false,
// Search state
searchTerm: '',
searchResults: null,
searchResultIndex: 0,
getIsSideBarVisible: () => get().isSideBarVisible,
getSideBarWidth: () => get().sideBarWidth,
setSideBarBookKey: (key: string) => set({ sideBarBookKey: key }),
@@ -28,4 +42,9 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
toggleSideBarPin: () => set((state) => ({ isSideBarPinned: !state.isSideBarPinned })),
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 }),
}));
+10
View File
@@ -0,0 +1,10 @@
import * as CFI from 'foliate-js/epubcfi.js';
export function isCfiInLocation(cfi: string, location: string | null | undefined): boolean {
if (!location) return false;
const start = CFI.collapse(location);
const end = CFI.collapse(location, true);
return CFI.compare(cfi, start) >= 0 && CFI.compare(cfi, end) <= 0;
}