import { useCallback, useMemo } from 'react'; import * as CFI from 'foliate-js/epubcfi.js'; import { useSidebarStore } from '@/store/sidebarStore'; import { useReaderStore } from '@/store/readerStore'; import { useBookProgress } from '@/store/readerProgressStore'; import { useBookDataStore } from '@/store/bookDataStore'; import { createCfiLocationMatcher } from '@/utils/cfi'; import { findTocItemBS } from '@/services/nav'; import { BookNoteType } from '@/types/book'; import { TOCItem } from '@/libs/document'; export function useBooknotesNav(bookKey: string, toc: TOCItem[]) { const getView = useReaderStore((s) => s.getView); const getConfig = useBookDataStore((s) => s.getConfig); const { setSideBarVisible, getBooknotesNavState, setActiveBooknoteType, setBooknoteResults, setBooknoteIndex, clearBooknotesNav, } = useSidebarStore(); const booknotesNavState = getBooknotesNavState(bookKey); const { activeBooknoteType, booknoteResults, booknoteIndex } = booknotesNavState; // Reactive: re-derives current-page boundaries when the user turns // the page. Reads from readerProgressStore only. const progress = useBookProgress(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. // Uses a batched CFI matcher so the location is collapsed only once per // page turn instead of once per booknote — see createCfiLocationMatcher // in utils/cfi for the why (hot-path CFI parsing was 16%+ of self time // in Android release-build profiles when annotations were dense). const currentPageResults = useMemo(() => { if (!sortedBooknotes.length || !currentLocation) return { firstIndex: -1, lastIndex: -1 }; const matches = createCfiLocationMatcher(currentLocation); let firstIndex = -1; let lastIndex = -1; for (let i = 0; i < sortedBooknotes.length; i++) { const note = sortedBooknotes[i]; if (note && matches(note.cfi)) { 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, }; }