Compare commits

..

5 Commits

Author SHA1 Message Date
Huang Xin c11f79e843 release: version 0.10.1 (#3588) 2026-03-22 13:29:13 +01:00
Huang Xin 87f0240b0a compat(footnote): support footnote text in alt attribute of the image, closes #3576 (#3587)
refactor: remove unused continuous scroll
2026-03-22 12:17:02 +01:00
Huang Xin 2905506017 fix(layout): fixed total scrollable width in vertical scrolled mode, closes #3583 (#3586) 2026-03-22 11:56:12 +01:00
Huang Xin 1936136596 fix: resolve various tracked exceptions in ph (#3584)
* fix: handle synced bookmarks without cfi

* chore: clean up some exceptions in ph
2026-03-22 08:16:38 +01:00
Huang Xin e0cf7e8d9f fix: handle number input value clip in settings on mobile devices (#3582) 2026-03-22 05:26:21 +01:00
28 changed files with 171 additions and 171 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ PDFJS_FONTS_PATH=../../packages/foliate-js/node_modules/pdfjs-dist
PDFJS_STYLE_PATH=../../packages/foliate-js/vendor/pdfjs
NEXT_PUBLIC_DEFAULT_POSTHOG_URL_BASE64="aHR0cHM6Ly91cy5pLnBvc3Rob2cuY29t"
NEXT_PUBLIC_DEFAULT_POSTHOG_KEY_BASE64="cGhjX2ViNXowbVRxWm8yZm5YYnZGNmE3bFh5TThpTmRSNTNsR1A3VFM3VGh4S08="
NEXT_PUBLIC_DEFAULT_POSTHOG_KEY_BASE64="cGhjX0x3ekhZRWtsZUVub3ZSc05ZQlRpTVRTV2MyS1NUOFdZMzBIWWFhN0ZPa1IK"
NEXT_PUBLIC_DEFAULT_SUPABASE_URL_BASE64="aHR0cHM6Ly9yZWFkZXN0LnN1cGFiYXNlLmNv"
NEXT_PUBLIC_DEFAULT_SUPABASE_KEY_BASE64="ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKemRYQmhZbUZ6WlNJc0luSmxaaUk2SW5aaWMzbDRablZ6YW1weFpIaHJhbkZzZVhOaklpd2ljbTlzWlNJNkltRnViMjRpTENKcFlYUWlPakUzTXpReE1qTTJOekVzSW1WNGNDSTZNakEwT1RZNU9UWTNNWDAuM1U1VXFhb3VfMVNnclZlMWVvOXJBcGMwdUtqcWhwUWRVWGh2d1VIbVVmZw=="
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.101",
"version": "0.10.1",
"private": true,
"type": "module",
"scripts": {
+13
View File
@@ -1,5 +1,18 @@
{
"releases": {
"0.10.1": {
"date": "2026-03-22",
"notes": [
"Reading: Added continuous scroll and spread layout support for EPUBs",
"Reading: Added current time and battery display in footer",
"Reading: Added TTS, annotation support, pinch-to-zoom, and scroll mode for PDFs",
"Library: Added backup/restore to a zip file and batch metadata refresh",
"KOReader: Added bookmark and annotation sync between KOReader and Readest devices",
"Footnotes: Added back navigation with link history and more responsive popup sizing",
"Annotations: Added loupe display when selecting text in instant annotation mode",
"Text-to-Speech: Fixed TTS reading position tracking in scrolled mode"
]
},
"0.9.101": {
"date": "2026-02-28",
"notes": [
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import React from 'react';
import NumberInput from '@/components/settings/NumberInput';
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
}));
afterEach(cleanup);
describe('NumberInput', () => {
const defaultProps = {
label: 'Font Size',
value: 16,
min: 8,
max: 72,
onChange: vi.fn(),
};
it('commits value on Enter key (form submit)', () => {
const onChange = vi.fn();
render(<NumberInput {...defaultProps} onChange={onChange} />);
const input = screen.getByDisplayValue('16');
fireEvent.change(input, { target: { value: '24' } });
expect(onChange).not.toHaveBeenCalled();
// Enter triggers form submit which commits the value
fireEvent.submit(input.closest('form')!);
expect(onChange).toHaveBeenCalledWith(24);
});
it('commits value on blur', () => {
const onChange = vi.fn();
render(<NumberInput {...defaultProps} onChange={onChange} />);
const input = screen.getByDisplayValue('16');
fireEvent.change(input, { target: { value: '20' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenCalledWith(20);
});
it('clamps value to min/max on commit', () => {
const onChange = vi.fn();
render(<NumberInput {...defaultProps} onChange={onChange} />);
const input = screen.getByDisplayValue('16');
fireEvent.change(input, { target: { value: '100' } });
fireEvent.submit(input.closest('form')!);
expect(onChange).toHaveBeenCalledWith(72);
});
});
@@ -18,6 +18,12 @@ describe('isCfiInLocation', () => {
expect(isCfiInLocation('epubcfi(/6/6)', null)).toBe(false);
expect(isCfiInLocation('epubcfi(/6/6)', undefined)).toBe(false);
});
it('should return false for null/undefined/empty cfi', () => {
expect(isCfiInLocation(null as unknown as string, 'epubcfi(/6/6)')).toBe(false);
expect(isCfiInLocation(undefined as unknown as string, 'epubcfi(/6/6)')).toBe(false);
expect(isCfiInLocation('', 'epubcfi(/6/6)')).toBe(false);
});
});
describe('findNearestCfi', () => {
@@ -264,7 +264,13 @@ const FoliateViewer: React.FC<{
item.style &&
getIndexFromCfi(item.cfi) === sectionIndex,
)
.forEach((annotation) => viewRef.current?.addAnnotation(annotation));
.map((annotation) => {
try {
viewRef.current?.addAnnotation(annotation);
} catch (err) {
console.warn('Failed to add annotation', { annotation, error: err });
}
});
}, 100);
if (!detail.doc.isEventListenersAdded) {
@@ -323,9 +329,9 @@ const FoliateViewer: React.FC<{
}
};
const { handlePageFlip, handleContinuousScroll } = usePagination(bookKey, viewRef, containerRef);
const mouseHandlers = useMouseEvent(bookKey, handlePageFlip, handleContinuousScroll);
const touchHandlers = useTouchEvent(bookKey, handlePageFlip, handleContinuousScroll);
const { handlePageFlip } = usePagination(bookKey, viewRef, containerRef);
const mouseHandlers = useMouseEvent(bookKey, handlePageFlip);
const touchHandlers = useTouchEvent(bookKey, handlePageFlip);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [selectedTableHtml, setSelectedTableHtml] = useState<string | null>(null);
@@ -54,10 +54,9 @@ const ProgressBar: React.FC<ProgressBarProps> = ({
const progressInfo = formatProgress(pageInfo?.current, pageInfo?.total, template, localize, lang);
const { page: current = 0, pages: total = 0 } = view?.renderer || {};
const pagesLeft = Math.min(
Math.max(total - current, 1),
pageInfo ? pageInfo.total - pageInfo.current : total,
);
const pagesLeft = bookData?.isFixedLayout
? 1
: Math.min(Math.max(total - current, 1), pageInfo ? pageInfo.total - pageInfo.current : total);
const showPagesLeft = total > 0 || bookData?.isFixedLayout;
const timeLeftStr = showPagesLeft
? _('{{time}} min left in chapter', {
@@ -162,7 +162,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
return (
<Menu
className={clsx(
'view-menu dropdown-content dropdown-right no-triangle z-20 mt-1 border',
'view-menu dropdown-content dropdown-right no-triangle z-20 mt-1.5 border',
'bgcolor-base-200 shadow-2xl',
)}
style={{
@@ -333,7 +333,11 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
getIndexFromCfi(booknote.cfi) === detail.index,
)
.map((annotation) => {
view?.addAnnotation(annotation);
try {
view?.addAnnotation(annotation);
} catch (err) {
console.warn('Failed to add annotation', { annotation, error: err });
}
});
};
@@ -48,7 +48,7 @@ const QuickActionMenu: React.FC<QuickActionMenuProps> = ({
return (
<Menu
className={clsx(
'annotation-quick-action-menu dropdown-content z-20 mt-1 border',
'annotation-quick-action-menu dropdown-content z-20 mt-1.5 border',
'bgcolor-base-200 shadow-2xl',
menuClassName,
)}
@@ -2,24 +2,18 @@ import { useEffect, useRef } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { debounce } from '@/utils/debounce';
import { ScrollSource } from './usePagination';
import { eventDispatcher } from '@/utils/event';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL } from '@/services/constants';
export const useMouseEvent = (
bookKey: string,
handlePageFlip: (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => void,
handleContinuousScroll: (source: ScrollSource, delta: number, threshold: number) => void,
) => {
const { hoveredBookKey } = useReaderStore();
const debounceScroll = debounce(handleContinuousScroll, 500);
const debounceFlip = debounce(handlePageFlip, 100);
const handleMouseEvent = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (msg instanceof MessageEvent) {
if (msg.data && msg.data.bookKey === bookKey) {
if (msg.data.type === 'iframe-wheel') {
debounceScroll('mouse', -msg.data.deltaY, 0);
}
if (msg.data.type === 'iframe-wheel') {
if (msg.data.ctrlKey) {
if (msg.data.deltaY > 0) {
@@ -34,10 +28,7 @@ export const useMouseEvent = (
handlePageFlip(msg);
}
}
} else if (msg.type === 'wheel') {
const event = msg as React.WheelEvent<HTMLDivElement>;
debounceScroll('mouse', -event.deltaY, 0);
} else {
} else if (msg.type !== 'wheel') {
handlePageFlip(msg);
}
};
@@ -92,11 +83,7 @@ interface IframeTouchEvent {
targetTouches: IframeTouch[];
}
export const useTouchEvent = (
bookKey: string,
handlePageFlip: (msg: CustomEvent) => void,
handleContinuousScroll: (source: ScrollSource, delta: number, threshold: number) => void,
) => {
export const useTouchEvent = (bookKey: string, handlePageFlip: (msg: CustomEvent) => void) => {
const { getBookData } = useBookDataStore();
const { hoveredBookKey, setHoveredBookKey, getViewSettings, getView } = useReaderStore();
@@ -241,7 +228,6 @@ export const useTouchEvent = (
},
}),
);
handleContinuousScroll('touch', deltaY, 30);
}
touchStartRef.current = null;
@@ -242,45 +242,6 @@ export const usePagination = (
}
};
const handleContinuousScroll = (mode: ScrollSource, scrollDelta: number, threshold: number) => {
const renderer = viewRef.current?.renderer;
const viewSettings = getViewSettings(bookKey)!;
const bookData = getBookData(bookKey)!;
// Continuous scroll is not supported in pre-paginated layout unless scrolled mode is active
if (bookData.bookDoc?.rendition?.layout === 'pre-paginated' && !viewSettings.scrolled) return;
if (renderer && viewSettings.scrolled && viewSettings.continuousScroll) {
const doScroll = () => {
// may have overscroll where the start is greater than 0
if (renderer.start <= scrollDelta && scrollDelta > threshold) {
setTimeout(() => {
viewRef.current?.prev(renderer.start + 1);
}, 100);
// sometimes viewSize has subpixel value that the end never reaches
} else if (
Math.ceil(renderer.end) - scrollDelta >= renderer.viewSize &&
scrollDelta < -threshold
) {
setTimeout(() => {
viewRef.current?.next(renderer.viewSize - Math.floor(renderer.end) + 1);
}, 100);
}
};
if (mode === 'mouse') {
// we can always get mouse wheel events
doScroll();
} else if (mode === 'touch') {
// when the document height is less than the viewport height, we can't get the relocate event
if (renderer.size >= renderer.viewSize) {
doScroll();
} else {
// scroll after the relocate event
renderer.addEventListener('relocate', () => doScroll(), { once: true });
}
}
}
};
useEffect(() => {
if (!appService?.isMobileApp) return;
@@ -301,6 +262,5 @@ export const usePagination = (
return {
handlePageFlip,
handleContinuousScroll,
};
};
@@ -15,7 +15,8 @@ export const useProgressAutoSave = (bookKey: string) => {
const saveBookConfig = useCallback(
debounce(() => {
setTimeout(async () => {
const config = getConfig(bookKey)!;
const config = getConfig(bookKey);
if (!config) return;
const settings = useSettingsStore.getState().settings;
await saveConfig(envConfig, bookKey, config, settings);
}, 500);
@@ -201,6 +201,7 @@ export const handleClick = (
footnote:
footnote.getAttribute('data-wr-footernote') ||
footnote.getAttribute('zy-footnote') ||
footnote.querySelector('img')?.getAttribute('alt') ||
footnote.getAttribute('alt') ||
element?.getAttribute('alt') ||
'',
@@ -27,7 +27,6 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
const [isScrolledMode, setScrolledMode] = useState(viewSettings.scrolled);
const [isContinuousScroll, setIsContinuousScroll] = useState(viewSettings.continuousScroll);
const [scrollingOverlap, setScrollingOverlap] = useState(viewSettings.scrollingOverlap);
const [hideScrollbar, setHideScrollbar] = useState(viewSettings.hideScrollbar || false);
const [volumeKeysToFlip, setVolumeKeysToFlip] = useState(viewSettings.volumeKeysToFlip);
@@ -56,7 +55,6 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
const handleReset = () => {
resetToDefaults({
scrolled: setScrolledMode,
continuousScroll: setIsContinuousScroll,
scrollingOverlap: setScrollingOverlap,
hideScrollbar: setHideScrollbar,
volumeKeysToFlip: setVolumeKeysToFlip,
@@ -95,11 +93,6 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hideScrollbar]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'continuousScroll', isContinuousScroll, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isContinuousScroll]);
useEffect(() => {
if (scrollingOverlap === viewSettings.scrollingOverlap) return;
saveViewSettings(envConfig, bookKey, 'scrollingOverlap', scrollingOverlap, false, false);
@@ -242,16 +235,6 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
onChange={() => setScrolledMode(!isScrolledMode)}
/>
</div>
<div className='config-item' data-setting-id='settings.control.continuousScroll'>
<span className=''>{_('Continuous Scroll')}</span>
<input
type='checkbox'
className='toggle'
checked={isContinuousScroll}
disabled={bookData?.isFixedLayout}
onChange={() => setIsContinuousScroll(!isContinuousScroll)}
/>
</div>
<NumberInput
label={_('Overlap Pixels')}
value={scrollingOverlap}
@@ -31,46 +31,36 @@ const NumberInput: React.FC<NumberInputProps> = ({
'data-setting-id': settingId,
}) => {
const _ = useTranslation();
const [localValue, setLocalValue] = useState(value);
const [displayValue, setDisplayValue] = useState(String(value));
const numberStep = step || 1;
useEffect(() => {
setLocalValue(value);
setDisplayValue(String(value));
}, [value]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Allow empty string or valid numbers without leading zeros
if (value === '' || /^[1-9]\d*\.?\d*$|^0?\.?\d*$/.test(value)) {
const newValue = value === '' ? 0 : parseFloat(value);
setLocalValue(newValue);
if (!isNaN(newValue)) {
const roundedValue = Math.round(newValue * 10) / 10;
onChange(Math.max(min, Math.min(max, roundedValue)));
}
const raw = e.target.value;
// Allow empty string or valid number fragments while typing
if (raw === '' || /^[0-9]*\.?[0-9]*$/.test(raw)) {
setDisplayValue(raw);
}
};
const increment = () => {
const newValue = Math.min(max, localValue + numberStep);
const roundedValue = Math.round(newValue * 10) / 10;
setLocalValue(roundedValue);
onChange(roundedValue);
const commitValue = (v: number) => {
const clamped = Math.round(Math.max(min, Math.min(max, v)) * 10) / 10;
setDisplayValue(String(clamped));
onChange(clamped);
};
const decrement = () => {
const newValue = Math.max(min, localValue - numberStep);
const roundedValue = Math.round(newValue * 10) / 10;
setLocalValue(roundedValue);
onChange(roundedValue);
};
const currentNumericValue = parseFloat(displayValue) || 0;
const handleOnBlur = () => {
const newValue = Math.max(min, Math.min(max, localValue));
setLocalValue(newValue);
onChange(newValue);
const increment = () => commitValue(currentNumericValue + numberStep);
const decrement = () => commitValue(currentNumericValue - numberStep);
const handleOnBlur = () => commitValue(currentNumericValue);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
commitValue(currentNumericValue);
(document.activeElement as HTMLElement)?.blur();
};
return (
@@ -78,26 +68,28 @@ const NumberInput: React.FC<NumberInputProps> = ({
<span className='text-base-content line-clamp-2'>{label}</span>
{iconSize && <span style={{ minWidth: `${iconSize}px` }} />}
<div className='text-base-content flex items-center gap-2'>
<input
type='text'
inputMode='decimal'
disabled={disabled}
value={localValue}
onChange={handleChange}
onBlur={handleOnBlur}
className={clsx(
'input input-ghost settings-content text-base-content w-16 max-w-xs rounded border-0 bg-transparent pe-3 !outline-none',
label && 'py-1 ps-1 text-right',
disabled && 'input-disabled cursor-not-allowed disabled:bg-transparent',
inputClassName,
)}
onFocus={(e) => e.target.select()}
/>
<form onSubmit={handleSubmit}>
<input
type='text'
inputMode='decimal'
disabled={disabled}
value={displayValue}
onChange={handleChange}
onBlur={handleOnBlur}
className={clsx(
'input input-ghost settings-content text-base-content w-16 max-w-xs rounded border-0 bg-transparent pe-3 !outline-none',
label && 'py-1 ps-1 text-right',
disabled && 'input-disabled cursor-not-allowed disabled:bg-transparent',
inputClassName,
)}
onFocus={(e) => e.target.select()}
/>
</form>
<button
tabIndex={disabled ? -1 : 0}
aria-label={_('Decrease')}
onClick={decrement}
className={`btn btn-circle btn-sm ${localValue <= min || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
className={`btn btn-circle btn-sm ${currentNumericValue <= min || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
>
<FiMinus className='h-4 w-4' />
</button>
@@ -105,7 +97,7 @@ const NumberInput: React.FC<NumberInputProps> = ({
tabIndex={disabled ? -1 : 0}
aria-label={_('Increase')}
onClick={increment}
className={`btn btn-circle btn-sm ${localValue >= max || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
className={`btn btn-circle btn-sm ${currentNumericValue >= max || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
>
<FiPlus className='h-4 w-4' />
</button>
@@ -23,6 +23,7 @@ if (typeof window !== 'undefined' && process.env['NODE_ENV'] === 'production' &&
posthog.init(posthogKey, {
api_host: posthogUrl,
person_profiles: 'always',
autocapture: false,
});
}
}
@@ -392,12 +392,6 @@ const controlPanelItems = [
keywords: ['scroll', 'scrolled', 'mode', 'paginate', 'continuous'],
section: 'Scroll',
},
{
id: 'settings.control.continuousScroll',
labelKey: _('Continuous Scroll'),
keywords: ['continuous', 'scroll', 'endless', 'infinite'],
section: 'Scroll',
},
{
id: 'settings.control.overlapPixels',
labelKey: _('Overlap Pixels'),
+1 -2
View File
@@ -87,7 +87,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
openLastBooks: false,
lastOpenBooks: [],
autoImportBooksOnOpen: false,
telemetryEnabled: false,
telemetryEnabled: true,
discordRichPresenceEnabled: false,
libraryViewMode: 'grid',
librarySortBy: LibrarySortByType.Updated,
@@ -184,7 +184,6 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
swapClickArea: false,
disableDoubleClick: false,
volumeKeysToFlip: false,
continuousScroll: false,
maxColumnCount: 2,
maxInlineSize: getDefaultMaxInlineSize(),
maxBlockSize: getDefaultMaxBlockSize(),
@@ -181,7 +181,7 @@ export class NativeTTSClient implements TTSClient {
const { marks } = parseSSMLMarks(ssml, this.#primaryLang);
for (const mark of marks) {
this.controller?.dispatchSpeakMark(mark);
if (!preload) this.controller?.dispatchSpeakMark(mark);
for await (const ev of this.speakMark(mark, preload, signal)) {
if (signal.aborted) {
yield { code: 'error', message: 'Aborted' } as TTSMessageEvent;
@@ -32,7 +32,7 @@ export class TTSController extends EventTarget {
#nossmlCnt: number = 0;
#currentSpeakAbortController: AbortController | null = null;
#currentSpeakPromise: Promise<void> | null = null;
#isPreloading: boolean = false;
#ttsSectionIndex: number = -1;
state: TTSState = 'stopped';
@@ -261,7 +261,6 @@ export class TTSController extends EventTarget {
const tts = this.view.tts;
if (!tts) return;
this.#isPreloading = true;
const ssmls: string[] = [];
for (let i = 0; i < count; i++) {
const ssml = await this.#preprocessSSML(tts.next());
@@ -271,7 +270,6 @@ export class TTSController extends EventTarget {
for (let i = 0; i < ssmls.length; i++) {
tts.prev();
}
this.#isPreloading = false;
await Promise.all(ssmls.map((ssml) => this.preloadSSML(ssml, new AbortController().signal)));
}
@@ -533,15 +531,11 @@ export class TTSController extends EventTarget {
dispatchSpeakMark(mark?: TTSMark) {
this.dispatchEvent(new CustomEvent('tts-speak-mark', { detail: mark || { text: '' } }));
if (mark && mark.name !== '-1') {
if (this.#isPreloading) {
setTimeout(() => this.dispatchSpeakMark(mark), 500);
} else {
try {
const range = this.view.tts?.setMark(mark.name);
try {
const cfi = this.view.getCFI(this.#ttsSectionIndex, range);
this.dispatchEvent(new CustomEvent('tts-highlight-mark', { detail: { cfi } }));
} catch {}
}
const cfi = this.view.getCFI(this.#ttsSectionIndex, range);
this.dispatchEvent(new CustomEvent('tts-highlight-mark', { detail: { cfi } }));
} catch {}
}
}
@@ -179,6 +179,8 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
}
}
}
// Filter out invalid booknotes
config.booknotes = config.booknotes?.filter((booknote) => booknote.cfi) ?? [];
await updateToc(
bookDoc,
config.viewSettings?.sortedTOC ?? false,
-1
View File
@@ -125,7 +125,6 @@ export interface BookLayout {
swapClickArea: boolean;
disableDoubleClick: boolean;
volumeKeysToFlip: boolean;
continuousScroll: boolean;
maxColumnCount: number;
maxInlineSize: number;
maxBlockSize: number;
+7 -2
View File
@@ -6,14 +6,19 @@ const unwrapCfi = (cfi: string): string => {
};
export function isCfiInLocation(cfi: string, location: string | null | undefined): boolean {
if (!location) return false;
if (!cfi || !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);
return CFI.compare(cfi, start) >= 0 && CFI.compare(cfi, end) <= 0;
try {
return CFI.compare(cfi, start) >= 0 && CFI.compare(cfi, end) <= 0;
} catch (err) {
console.warn('Failed to compare CFIs', { cfi, location, error: err });
return false;
}
}
/**
+1
View File
@@ -19,6 +19,7 @@ export const findParentPath = (toc: TOCItem[], href: string): TOCItem[] => {
};
export const findTocItemBS = (toc: TOCItem[], cfi: string): TOCItem | null => {
if (!cfi) return null;
let left = 0;
let right = toc.length - 1;
let result: TOCItem | null = null;
+14 -14
View File
@@ -59,6 +59,20 @@
<launchable type="desktop-id">com.bilingify.readest.desktop</launchable>
<releases>
<release version="0.10.1" date="2026-03-22">
<description>
<ul>
<li>Reading: Added continuous scroll and spread layout support for EPUBs</li>
<li>Reading: Added current time and battery display in footer</li>
<li>Reading: Added TTS, annotation support, pinch-to-zoom, and scroll mode for PDFs</li>
<li>Library: Added backup/restore to a zip file and batch metadata refresh</li>
<li>KOReader: Added bookmark and annotation sync between KOReader and Readest devices</li>
<li>Footnotes: Added back navigation with link history and more responsive popup sizing</li>
<li>Annotations: Added loupe display when selecting text in instant annotation mode</li>
<li>Text-to-Speech: Fixed TTS reading position tracking in scrolled mode</li>
</ul>
</description>
</release>
<release version="0.9.101" date="2026-02-28">
<description>
<ul>
@@ -225,20 +239,6 @@
</ul>
</description>
</release>
<release version="0.9.92" date="2025-11-18">
<description>
<ul>
<li>Bookshelf: Added support for nested groups to organize your books more flexibly</li>
<li>Sync: Improved reliability by automatically retrying failed book syncs</li>
<li>Sync: Improved token refresh so login sessions stay active longer on KOReader</li>
<li>E-Ink: Enhanced readability on the library page for E-Ink devices</li>
<li>UI: Storage capacity is now displayed with precise values in the user profile</li>
<li>Reader: You can now import files directly into the currently selected book group</li>
<li>Account: You can now update the email address linked to your Readest account</li>
<li>Footnotes: Added compatibility for more footnote formats</li>
</ul>
</description>
</release>
</releases>
<metadata_license>FSFAP</metadata_license>
+1 -1
View File
@@ -1 +1 @@
270dd7735b8f9b914accdc114bbe06fc92767ceaddc967380d386cfcc51f0952 ../../data/metainfo/appdata.xml
53a79633d376f530408442fa820c5b95e3f72bae867192d3deb0a0612cdef28f ../../data/metainfo/appdata.xml