forked from akai/readest
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c11f79e843 | |||
| 87f0240b0a | |||
| 2905506017 | |||
| 1936136596 | |||
| e0cf7e8d9f |
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.101",
|
||||
"version": "0.10.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -125,7 +125,6 @@ export interface BookLayout {
|
||||
swapClickArea: boolean;
|
||||
disableDoubleClick: boolean;
|
||||
volumeKeysToFlip: boolean;
|
||||
continuousScroll: boolean;
|
||||
maxColumnCount: number;
|
||||
maxInlineSize: number;
|
||||
maxBlockSize: number;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
270dd7735b8f9b914accdc114bbe06fc92767ceaddc967380d386cfcc51f0952 ../../data/metainfo/appdata.xml
|
||||
53a79633d376f530408442fa820c5b95e3f72bae867192d3deb0a0612cdef28f ../../data/metainfo/appdata.xml
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 57e35351b1...f015af2435
Reference in New Issue
Block a user