diff --git a/apps/readest-app/src/__tests__/utils/cfi.test.ts b/apps/readest-app/src/__tests__/utils/cfi.test.ts new file mode 100644 index 00000000..3b9b6eea --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/cfi.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { isCfiInLocation, findNearestCfi } from '@/utils/cfi'; + +describe('isCfiInLocation', () => { + it('should return true when cfi path starts with location path', () => { + expect(isCfiInLocation('epubcfi(/6/6!/4/4/54,/1:4,/1:15)', 'epubcfi(/6/6)')).toBe(true); + }); + + it('should return true for exact match', () => { + expect(isCfiInLocation('epubcfi(/6/6)', 'epubcfi(/6/6)')).toBe(true); + }); + + it('should return false when cfi is in a different section', () => { + expect(isCfiInLocation('epubcfi(/6/8!/4/4/54,/1:4,/1:15)', 'epubcfi(/6/6)')).toBe(false); + }); + + it('should return false for null/undefined location', () => { + expect(isCfiInLocation('epubcfi(/6/6)', null)).toBe(false); + expect(isCfiInLocation('epubcfi(/6/6)', undefined)).toBe(false); + }); +}); + +describe('findNearestCfi', () => { + const sortedCfis = [ + 'epubcfi(/6/4!/4/2:0)', + 'epubcfi(/6/6!/4/4:0)', + 'epubcfi(/6/6!/4/10:0)', + 'epubcfi(/6/8!/4/2:0)', + 'epubcfi(/6/10!/4/6:0)', + ]; + + it('should return the nearest cfi before the location', () => { + // location is between index 1 and 2 — nearest is index 1 + const result = findNearestCfi(sortedCfis, 'epubcfi(/6/6!/4/6:0)'); + expect(result).toBe('epubcfi(/6/6!/4/4:0)'); + }); + + it('should return the last cfi when location is after all items', () => { + const result = findNearestCfi(sortedCfis, 'epubcfi(/6/20!/4/2:0)'); + expect(result).toBe('epubcfi(/6/10!/4/6:0)'); + }); + + it('should return the first cfi when location is before all items', () => { + const result = findNearestCfi(sortedCfis, 'epubcfi(/6/2!/4/2:0)'); + expect(result).toBe('epubcfi(/6/4!/4/2:0)'); + }); + + it('should return exact match when location matches a cfi', () => { + const result = findNearestCfi(sortedCfis, 'epubcfi(/6/6!/4/4:0)'); + expect(result).toBe('epubcfi(/6/6!/4/4:0)'); + }); + + it('should return null for empty array', () => { + expect(findNearestCfi([], 'epubcfi(/6/6!/4/4:0)')).toBeNull(); + }); + + it('should return null for null/undefined location', () => { + expect(findNearestCfi(sortedCfis, null)).toBeNull(); + expect(findNearestCfi(sortedCfis, undefined)).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/ProgressBar.tsx b/apps/readest-app/src/app/reader/components/ProgressBar.tsx index a93748ef..15bd1cdb 100644 --- a/apps/readest-app/src/app/reader/components/ProgressBar.tsx +++ b/apps/readest-app/src/app/reader/components/ProgressBar.tsx @@ -58,7 +58,7 @@ const ProgressBar: React.FC = ({ Math.max(total - current, 1), pageInfo ? pageInfo.total - pageInfo.current : total, ); - const showPagesLeft = total > 0; + const showPagesLeft = total > 0 || bookData?.isFixedLayout; const timeLeftStr = showPagesLeft ? _('{{time}} min left in chapter', { time: formatNumber( diff --git a/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx b/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx index be8bfe0f..3b037551 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx @@ -21,10 +21,11 @@ import TextEditor, { TextEditorRef } from '@/components/TextEditor'; interface BooknoteItemProps { bookKey: string; item: BookNote; + isNearest?: boolean; onClick?: () => void; } -const BooknoteItem: React.FC = ({ bookKey, item, onClick }) => { +const BooknoteItem: React.FC = ({ bookKey, item, isNearest, onClick }) => { const _ = useTranslation(); const { envConfig } = useEnv(); const { settings } = useSettingsStore(); @@ -43,7 +44,7 @@ const BooknoteItem: React.FC = ({ bookKey, item, onClick }) = const size18 = useResponsiveSize(18); const progress = getProgress(bookKey); - const { isCurrent, viewRef } = useScrollToItem(cfi, progress); + const { isCurrent, viewRef } = useScrollToItem(cfi, progress, isNearest); const handleClickItem = (event: React.MouseEvent | React.KeyboardEvent) => { event.preventDefault(); diff --git a/apps/readest-app/src/app/reader/components/sidebar/BooknoteView.tsx b/apps/readest-app/src/app/reader/components/sidebar/BooknoteView.tsx index 2ac161d6..9cb2ae6e 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/BooknoteView.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/BooknoteView.tsx @@ -1,8 +1,10 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import * as CFI from 'foliate-js/epubcfi.js'; import { useBookDataStore } from '@/store/bookDataStore'; +import { useReaderStore } from '@/store/readerStore'; import { useSidebarStore } from '@/store/sidebarStore'; import { findTocItemBS } from '@/utils/toc'; +import { findNearestCfi } from '@/utils/cfi'; import { TOCItem } from '@/libs/document'; import { BooknoteGroup, BookNoteType } from '@/types/book'; import BooknoteItem from './BooknoteItem'; @@ -13,8 +15,10 @@ const BooknoteView: React.FC<{ toc: TOCItem[]; }> = ({ type, bookKey, toc }) => { const { getConfig } = useBookDataStore(); + const { getProgress } = useReaderStore(); const { setActiveBooknoteType, setBooknoteResults } = useSidebarStore(); const config = getConfig(bookKey)!; + const progress = getProgress(bookKey); const { booknotes: allNotes = [] } = config; const booknotes = allNotes.filter((note) => note.type === type && !note.deletedAt); @@ -41,6 +45,12 @@ const BooknoteView: React.FC<{ return a.id - b.id; }); + const nearestCfi = useMemo(() => { + const allSorted = sortedGroups.flatMap((g) => g.booknotes.map((n) => n.cfi)); + return findNearestCfi(allSorted, progress?.location); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [progress?.location, sortedGroups.length]); + const handleBrowseBookNotes = () => { if (booknotes.length === 0) return; @@ -61,6 +71,7 @@ const BooknoteView: React.FC<{ key={`${index}-${item.cfi}`} bookKey={bookKey} item={item} + isNearest={item.cfi === nearestCfi} onClick={handleBrowseBookNotes} /> ))} diff --git a/apps/readest-app/src/app/reader/components/sidebar/SearchResults.tsx b/apps/readest-app/src/app/reader/components/sidebar/SearchResults.tsx index f74888d5..0ea527b4 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/SearchResults.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/SearchResults.tsx @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { BookSearchMatch, BookSearchResult, SearchExcerpt } from '@/types/book'; import { useReaderStore } from '@/store/readerStore'; +import { findNearestCfi } from '@/utils/cfi'; import useScrollToItem from '../../hooks/useScrollToItem'; import clsx from 'clsx'; @@ -8,6 +9,7 @@ interface SearchResultItemProps { bookKey: string; cfi: string; excerpt: SearchExcerpt; + isNearest?: boolean; onSelectResult: (cfi: string) => void; } @@ -15,11 +17,12 @@ const SearchResultItem: React.FC = ({ bookKey, cfi, excerpt, + isNearest, onSelectResult, }) => { const { getProgress } = useReaderStore(); const progress = getProgress(bookKey)!; - const { isCurrent, viewRef } = useScrollToItem(cfi, progress); + const { isCurrent, viewRef } = useScrollToItem(cfi, progress, isNearest); return (
  • = ({ bookKey, results, onSelectResult }) => { + const { getProgress } = useReaderStore(); + const progress = getProgress(bookKey); + + const nearestCfi = useMemo(() => { + const allCfis: string[] = []; + for (const result of results) { + if ('subitems' in result) { + for (const item of result.subitems) allCfis.push(item.cfi); + } else { + allCfis.push(result.cfi); + } + } + return findNearestCfi(allCfis, progress?.location); + }, [progress?.location, results]); + return (
      @@ -70,6 +88,7 @@ const SearchResults: React.FC = ({ bookKey, results, onSelec bookKey={bookKey} cfi={item.cfi} excerpt={item.excerpt} + isNearest={item.cfi === nearestCfi} onSelectResult={onSelectResult} /> ))} @@ -83,6 +102,7 @@ const SearchResults: React.FC = ({ bookKey, results, onSelec bookKey={bookKey} cfi={result.cfi} excerpt={result.excerpt} + isNearest={result.cfi === nearestCfi} onSelectResult={onSelectResult} /> ); diff --git a/apps/readest-app/src/app/reader/hooks/useScrollToItem.ts b/apps/readest-app/src/app/reader/hooks/useScrollToItem.ts index 6d830f05..d095d4d0 100644 --- a/apps/readest-app/src/app/reader/hooks/useScrollToItem.ts +++ b/apps/readest-app/src/app/reader/hooks/useScrollToItem.ts @@ -2,15 +2,19 @@ import { useEffect, useMemo, useRef } from 'react'; import { BookProgress } from '@/types/book'; import { isCfiInLocation } from '@/utils/cfi'; -const useScrollToItem = (cfi: string, progress: BookProgress | null) => { +const useScrollToItem = ( + cfi: string, + progress: BookProgress | null, + isNearest: boolean = false, +) => { const viewRef = useRef(null); const isCurrent = useMemo(() => isCfiInLocation(cfi, progress?.location), [cfi, progress]); + const shouldScroll = isCurrent || isNearest; useEffect(() => { - if (!viewRef.current || !isCurrent) return; + if (!viewRef.current || !shouldScroll) return; - // Scroll to the item if it's the current one and not visible const element = viewRef.current; const rect = element.getBoundingClientRect(); const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight; @@ -19,8 +23,10 @@ const useScrollToItem = (cfi: string, progress: BookProgress | null) => { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); } - element.setAttribute('aria-current', 'page'); - }, [isCurrent]); + if (isCurrent) { + element.setAttribute('aria-current', 'page'); + } + }, [shouldScroll, isCurrent]); return { isCurrent, viewRef }; }; diff --git a/apps/readest-app/src/utils/cfi.ts b/apps/readest-app/src/utils/cfi.ts index 3e162ebd..80459a5d 100644 --- a/apps/readest-app/src/utils/cfi.ts +++ b/apps/readest-app/src/utils/cfi.ts @@ -1,7 +1,14 @@ import * as CFI from 'foliate-js/epubcfi.js'; +const unwrapCfi = (cfi: string): string => { + const match = cfi.match(/^epubcfi\((.+)\)$/); + return match ? match[1]! : cfi; +}; + export function isCfiInLocation(cfi: string, location: string | null | undefined): boolean { if (!location) return false; + if (cfi === location) return true; + if (cfi && unwrapCfi(cfi).startsWith(unwrapCfi(location))) return true; const start = CFI.collapse(location); const end = CFI.collapse(location, true); @@ -9,6 +16,36 @@ export function isCfiInLocation(cfi: string, location: string | null | undefined return CFI.compare(cfi, start) >= 0 && CFI.compare(cfi, end) <= 0; } +/** + * Binary search a sorted CFI array to find the nearest CFI to a location. + * Returns the CFI of the item just before or at the location. + */ +export function findNearestCfi( + sortedCfis: string[], + location: string | null | undefined, +): string | null { + if (!location || sortedCfis.length === 0) return null; + + const target = CFI.collapse(location); + let lo = 0; + let hi = sortedCfis.length; + + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (CFI.compare(sortedCfis[mid]!, target) <= 0) { + lo = mid + 1; + } else { + hi = mid; + } + } + + // lo is the first index where cfi > target + // The nearest item at or before target is lo - 1 + if (lo === 0) return sortedCfis[0]!; + if (lo >= sortedCfis.length) return sortedCfis[sortedCfis.length - 1]!; + return sortedCfis[lo - 1]!; +} + export function getIndexFromCfi(cfi: string): number | null { try { const parts = CFI.parse(cfi); diff --git a/packages/foliate-js b/packages/foliate-js index 38115b12..1dd2d3d8 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 38115b12203e03dcaa5d4dd89a1460b0168087c0 +Subproject commit 1dd2d3d87e7064c77babbbff9d4e3c2634a322ea