feat: scroll to the nearest item in the search results or annotation lists (#3555)

This commit is contained in:
Huang Xin
2026-03-17 23:01:56 +08:00
committed by GitHub
parent b2bb92036b
commit 1f7483b911
8 changed files with 148 additions and 12 deletions
@@ -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();
});
});
@@ -58,7 +58,7 @@ const ProgressBar: React.FC<ProgressBarProps> = ({
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(
@@ -21,10 +21,11 @@ import TextEditor, { TextEditorRef } from '@/components/TextEditor';
interface BooknoteItemProps {
bookKey: string;
item: BookNote;
isNearest?: boolean;
onClick?: () => void;
}
const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, onClick }) => {
const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, isNearest, onClick }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
@@ -43,7 +44,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ 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();
@@ -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}
/>
))}
@@ -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<SearchResultItemProps> = ({
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 (
<li
@@ -55,6 +58,21 @@ interface SearchResultsProps {
}
const SearchResults: React.FC<SearchResultsProps> = ({ 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 (
<div className='search-results overflow-y-auto p-2 font-sans text-sm font-light'>
<ul className='px-2'>
@@ -70,6 +88,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({ 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<SearchResultsProps> = ({ bookKey, results, onSelec
bookKey={bookKey}
cfi={result.cfi}
excerpt={result.excerpt}
isNearest={result.cfi === nearestCfi}
onSelectResult={onSelectResult}
/>
);
@@ -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<HTMLLIElement | null>(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 };
};
+37
View File
@@ -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);