forked from akai/readest
feat: scroll to the nearest item in the search results or annotation lists (#3555)
This commit is contained in:
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 38115b1220...1dd2d3d87e
Reference in New Issue
Block a user