feat(rsvp): split words option, faster countdown, and skip pages RSVP cant open (#3755)
* fix: use string-parsed year to avoid UTC/timezone boundary issues * feat: Add option to split words in RSVP mode * fix: change the checkbox to a toggle * fix(rsvp): speed up countdown from 2.4s to 1.5s * feat(rsvp): skip to next page when no readable text is found * fix(rsvp): replace lookbehind regex with lookahead-only split in getHyphenParts
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
|||||||
getSegmenterLocale,
|
getSegmenterLocale,
|
||||||
segmentCJKText,
|
segmentCJKText,
|
||||||
splitTextIntoWords,
|
splitTextIntoWords,
|
||||||
|
getHyphenParts,
|
||||||
} from '@/services/rsvp/utils';
|
} from '@/services/rsvp/utils';
|
||||||
|
|
||||||
describe('rsvp/utils', () => {
|
describe('rsvp/utils', () => {
|
||||||
@@ -240,4 +241,38 @@ describe('rsvp/utils', () => {
|
|||||||
expect(words.length).toBeGreaterThan(0);
|
expect(words.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getHyphenParts', () => {
|
||||||
|
test('splits a hyphenated word into two parts with trailing hyphen on first', () => {
|
||||||
|
expect(getHyphenParts('well-known')).toEqual(['well-', 'known']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('splits multiple letter-hyphens keeping trailing hyphen on each non-last part', () => {
|
||||||
|
expect(getHyphenParts('one-two-three')).toEqual(['one-', 'two-', 'three']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns word unchanged when no letter-hyphen-letter pattern', () => {
|
||||||
|
expect(getHyphenParts('hello')).toEqual(['hello']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns double-hyphen unchanged (em-dash style)', () => {
|
||||||
|
expect(getHyphenParts('--')).toEqual(['--']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns lone hyphen unchanged', () => {
|
||||||
|
expect(getHyphenParts('-')).toEqual(['-']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns consecutive-hyphen word unchanged', () => {
|
||||||
|
expect(getHyphenParts('foo--bar')).toEqual(['foo--bar']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns leading-hyphen word unchanged', () => {
|
||||||
|
expect(getHyphenParts('-word')).toEqual(['-word']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns trailing-hyphen word unchanged', () => {
|
||||||
|
expect(getHyphenParts('word-')).toEqual(['word-']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
|||||||
const _ = useTranslation();
|
const _ = useTranslation();
|
||||||
const { themeCode, isDarkMode: _isDarkMode } = useThemeStore();
|
const { themeCode, isDarkMode: _isDarkMode } = useThemeStore();
|
||||||
const [state, setState] = useState<RsvpState>(controller.currentState);
|
const [state, setState] = useState<RsvpState>(controller.currentState);
|
||||||
const currentWord = state.words[state.currentIndex] ?? null;
|
const currentWord = controller.currentDisplayWord;
|
||||||
const [countdown, setCountdown] = useState<number | null>(controller.currentCountdown);
|
const [countdown, setCountdown] = useState<number | null>(controller.currentCountdown);
|
||||||
const [showChapterDropdown, setShowChapterDropdown] = useState(false);
|
const [showChapterDropdown, setShowChapterDropdown] = useState(false);
|
||||||
const chapterDropdownRef = useRef<HTMLDivElement>(null);
|
const chapterDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -93,15 +93,10 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
// Context window: only rebuild the rendered word list when the current word
|
const [contextWindow, setContextWindow] = useState(() => ({
|
||||||
// falls outside the window or nears its edge, keeping the DOM stable.
|
start: 0,
|
||||||
const [contextWindow, setContextWindow] = useState(() => {
|
end: state.words.length,
|
||||||
if (!state || state.words.length === 0) return { start: 0, end: 0 };
|
}));
|
||||||
return {
|
|
||||||
start: Math.max(0, state.currentIndex - 50),
|
|
||||||
end: Math.min(state.words.length, state.currentIndex + 151),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const contextWordRef = useRef<HTMLSpanElement>(null);
|
const contextWordRef = useRef<HTMLSpanElement>(null);
|
||||||
const contextPanelRef = useRef<HTMLDivElement>(null);
|
const contextPanelRef = useRef<HTMLDivElement>(null);
|
||||||
const touchStartX = useRef(0);
|
const touchStartX = useRef(0);
|
||||||
@@ -134,25 +129,11 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
|||||||
const newState = (e as CustomEvent<RsvpState>).detail;
|
const newState = (e as CustomEvent<RsvpState>).detail;
|
||||||
setState(newState);
|
setState(newState);
|
||||||
|
|
||||||
// Update context window only when current word falls outside or nears edge
|
// Reset context window to show all words when the chapter changes
|
||||||
const idx = newState.currentIndex;
|
|
||||||
const total = newState.words.length;
|
const total = newState.words.length;
|
||||||
setContextWindow((prev) => {
|
setContextWindow((prev) => {
|
||||||
if (total === 0) return { start: 0, end: 0 };
|
if (total === prev.end && prev.start === 0) return prev;
|
||||||
// Outside window — reset
|
return { start: 0, end: total };
|
||||||
if (idx < prev.start || idx >= prev.end) {
|
|
||||||
return {
|
|
||||||
start: Math.max(0, idx - 50),
|
|
||||||
end: Math.min(total, idx + 151),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Near end of window — extend forward
|
|
||||||
const windowSize = prev.end - prev.start;
|
|
||||||
if (idx - prev.start > windowSize * 0.8) {
|
|
||||||
return { start: prev.start, end: Math.min(total, prev.end + 100) };
|
|
||||||
}
|
|
||||||
// No change — return same reference to avoid re-render
|
|
||||||
return prev;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -851,6 +832,17 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Split hyphenated words */}
|
||||||
|
<div className='config-item gap-2'>
|
||||||
|
<span className='opacity-50'>{_('Split Hyphens')}</span>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
className='toggle'
|
||||||
|
checked={state.splitHyphens}
|
||||||
|
onChange={(e) => controller.setSplitHyphens(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ORP color */}
|
{/* ORP color */}
|
||||||
<div className='flex items-center gap-1.5'>
|
<div className='flex items-center gap-1.5'>
|
||||||
<span className='mr-0.5 font-medium opacity-50'>{_('Focus')}</span>
|
<span className='mr-0.5 font-medium opacity-50'>{_('Focus')}</span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FoliateView } from '@/types/view';
|
import { FoliateView } from '@/types/view';
|
||||||
import { RsvpWord, RsvpState, RsvpPosition, RsvpStopPosition, RsvpStartChoice } from './types';
|
import { RsvpWord, RsvpState, RsvpPosition, RsvpStopPosition, RsvpStartChoice } from './types';
|
||||||
import { containsCJK, splitTextIntoWords } from './utils';
|
import { containsCJK, splitTextIntoWords, getHyphenParts } from './utils';
|
||||||
import { compare as compareCFI } from 'foliate-js/epubcfi.js';
|
import { compare as compareCFI } from 'foliate-js/epubcfi.js';
|
||||||
import { XCFI } from '@/utils/xcfi';
|
import { XCFI } from '@/utils/xcfi';
|
||||||
|
|
||||||
@@ -10,9 +10,11 @@ const MAX_WPM = 1000;
|
|||||||
const WPM_STEP = 50;
|
const WPM_STEP = 50;
|
||||||
const DEFAULT_PUNCTUATION_PAUSE_MS = 100;
|
const DEFAULT_PUNCTUATION_PAUSE_MS = 100;
|
||||||
const PUNCTUATION_PAUSE_OPTIONS = [25, 50, 75, 100, 125, 150, 175, 200];
|
const PUNCTUATION_PAUSE_OPTIONS = [25, 50, 75, 100, 125, 150, 175, 200];
|
||||||
|
const DEFAULT_SPLIT_HYPHENS = false;
|
||||||
const STORAGE_KEY_PREFIX = 'readest_rsvp_wpm_';
|
const STORAGE_KEY_PREFIX = 'readest_rsvp_wpm_';
|
||||||
const PUNCTUATION_PAUSE_KEY_PREFIX = 'readest_rsvp_pause_';
|
const PUNCTUATION_PAUSE_KEY_PREFIX = 'readest_rsvp_pause_';
|
||||||
const POSITION_KEY_PREFIX = 'readest_rsvp_pos_';
|
const POSITION_KEY_PREFIX = 'readest_rsvp_pos_';
|
||||||
|
const SPLIT_HYPHENS_KEY = 'readest_rsvp_split_hyphens';
|
||||||
|
|
||||||
export class RSVPController extends EventTarget {
|
export class RSVPController extends EventTarget {
|
||||||
private view: FoliateView;
|
private view: FoliateView;
|
||||||
@@ -24,8 +26,10 @@ export class RSVPController extends EventTarget {
|
|||||||
playing: false,
|
playing: false,
|
||||||
words: [],
|
words: [],
|
||||||
currentIndex: 0,
|
currentIndex: 0,
|
||||||
|
currentPartIndex: 0,
|
||||||
wpm: DEFAULT_WPM,
|
wpm: DEFAULT_WPM,
|
||||||
punctuationPauseMs: DEFAULT_PUNCTUATION_PAUSE_MS,
|
punctuationPauseMs: DEFAULT_PUNCTUATION_PAUSE_MS,
|
||||||
|
splitHyphens: DEFAULT_SPLIT_HYPHENS,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,6 +56,10 @@ export class RSVPController extends EventTarget {
|
|||||||
if (savedPause) {
|
if (savedPause) {
|
||||||
this.state.punctuationPauseMs = savedPause;
|
this.state.punctuationPauseMs = savedPause;
|
||||||
}
|
}
|
||||||
|
const savedSplitHyphens = this.loadSplitHyphensFromStorage();
|
||||||
|
if (savedSplitHyphens !== null) {
|
||||||
|
this.state.splitHyphens = savedSplitHyphens;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get currentState(): RsvpState {
|
get currentState(): RsvpState {
|
||||||
@@ -69,6 +77,16 @@ export class RSVPController extends EventTarget {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get currentDisplayWord(): RsvpWord | null {
|
||||||
|
const word = this.currentWord;
|
||||||
|
if (!word) return null;
|
||||||
|
if (!this.state.splitHyphens) return word;
|
||||||
|
const parts = getHyphenParts(word.text);
|
||||||
|
if (parts.length <= 1) return word;
|
||||||
|
const partText = parts[this.state.currentPartIndex] ?? word.text;
|
||||||
|
return { ...word, text: partText, orpIndex: this.calculateORP(partText) };
|
||||||
|
}
|
||||||
|
|
||||||
get currentCountdown(): number | null {
|
get currentCountdown(): number | null {
|
||||||
return this.countdown;
|
return this.countdown;
|
||||||
}
|
}
|
||||||
@@ -130,6 +148,30 @@ export class RSVPController extends EventTarget {
|
|||||||
localStorage.setItem(`${STORAGE_KEY_PREFIX}${this.bookId}`, wpm.toString());
|
localStorage.setItem(`${STORAGE_KEY_PREFIX}${this.bookId}`, wpm.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSplitHyphens(): boolean {
|
||||||
|
return this.state.splitHyphens;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSplitHyphens(value: boolean): void {
|
||||||
|
this.state.splitHyphens = value;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SPLIT_HYPHENS_KEY, value ? '1' : '0');
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.emitStateChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadSplitHyphensFromStorage(): boolean | null {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(SPLIT_HYPHENS_KEY);
|
||||||
|
if (stored !== null) return stored === '1';
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
setCurrentCfi(cfi: string | null): void {
|
setCurrentCfi(cfi: string | null): void {
|
||||||
this.currentCfi = cfi;
|
this.currentCfi = cfi;
|
||||||
}
|
}
|
||||||
@@ -208,6 +250,7 @@ export class RSVPController extends EventTarget {
|
|||||||
setTimeout(() => this.start(retryCount + 1), 150 * (retryCount + 1));
|
setTimeout(() => this.start(retryCount + 1), 150 * (retryCount + 1));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.dispatchEvent(new CustomEvent('rsvp-request-next-page'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +320,7 @@ export class RSVPController extends EventTarget {
|
|||||||
this.clearCountdown();
|
this.clearCountdown();
|
||||||
onComplete();
|
onComplete();
|
||||||
}
|
}
|
||||||
}, 800);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearCountdown(): void {
|
private clearCountdown(): void {
|
||||||
@@ -323,6 +366,7 @@ export class RSVPController extends EventTarget {
|
|||||||
playing: false,
|
playing: false,
|
||||||
words: [],
|
words: [],
|
||||||
currentIndex: 0,
|
currentIndex: 0,
|
||||||
|
currentPartIndex: 0,
|
||||||
};
|
};
|
||||||
this.emitStateChange();
|
this.emitStateChange();
|
||||||
}
|
}
|
||||||
@@ -475,11 +519,13 @@ export class RSVPController extends EventTarget {
|
|||||||
this.state.words.length - 1,
|
this.state.words.length - 1,
|
||||||
this.state.currentIndex + count,
|
this.state.currentIndex + count,
|
||||||
);
|
);
|
||||||
|
this.state.currentPartIndex = 0;
|
||||||
this.emitStateChange();
|
this.emitStateChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
skipBackward(count: number = 10): void {
|
skipBackward(count: number = 10): void {
|
||||||
this.state.currentIndex = Math.max(0, this.state.currentIndex - count);
|
this.state.currentIndex = Math.max(0, this.state.currentIndex - count);
|
||||||
|
this.state.currentPartIndex = 0;
|
||||||
this.emitStateChange();
|
this.emitStateChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,12 +533,14 @@ export class RSVPController extends EventTarget {
|
|||||||
if (this.state.words.length === 0) return;
|
if (this.state.words.length === 0) return;
|
||||||
const newIndex = Math.floor((percentage / 100) * this.state.words.length);
|
const newIndex = Math.floor((percentage / 100) * this.state.words.length);
|
||||||
this.state.currentIndex = Math.max(0, Math.min(this.state.words.length - 1, newIndex));
|
this.state.currentIndex = Math.max(0, Math.min(this.state.words.length - 1, newIndex));
|
||||||
|
this.state.currentPartIndex = 0;
|
||||||
this.emitStateChange();
|
this.emitStateChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
seekToIndex(index: number): void {
|
seekToIndex(index: number): void {
|
||||||
if (this.state.words.length === 0) return;
|
if (this.state.words.length === 0) return;
|
||||||
this.state.currentIndex = Math.max(0, Math.min(this.state.words.length - 1, index));
|
this.state.currentIndex = Math.max(0, Math.min(this.state.words.length - 1, index));
|
||||||
|
this.state.currentPartIndex = 0;
|
||||||
this.emitStateChange();
|
this.emitStateChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,7 +552,7 @@ export class RSVPController extends EventTarget {
|
|||||||
setTimeout(() => this.loadNextPageContent(retryCount + 1), 200 * (retryCount + 1));
|
setTimeout(() => this.loadNextPageContent(retryCount + 1), 200 * (retryCount + 1));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.pause();
|
this.dispatchEvent(new CustomEvent('rsvp-request-next-page'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,6 +562,7 @@ export class RSVPController extends EventTarget {
|
|||||||
playing: false,
|
playing: false,
|
||||||
words,
|
words,
|
||||||
currentIndex: 0,
|
currentIndex: 0,
|
||||||
|
currentPartIndex: 0,
|
||||||
};
|
};
|
||||||
this.emitStateChange();
|
this.emitStateChange();
|
||||||
|
|
||||||
@@ -536,8 +585,8 @@ export class RSVPController extends EventTarget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const word = this.state.words[this.state.currentIndex]!;
|
const displayWord = this.currentDisplayWord!;
|
||||||
const duration = this.getWordDisplayDuration(word, this.state.wpm);
|
const duration = this.getWordDisplayDuration(displayWord, this.state.wpm);
|
||||||
|
|
||||||
this.playbackTimer = setTimeout(() => {
|
this.playbackTimer = setTimeout(() => {
|
||||||
this.advanceToNextWord();
|
this.advanceToNextWord();
|
||||||
@@ -545,6 +594,17 @@ export class RSVPController extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private advanceToNextWord(): void {
|
private advanceToNextWord(): void {
|
||||||
|
const word = this.currentWord;
|
||||||
|
if (word && this.state.splitHyphens) {
|
||||||
|
const parts = getHyphenParts(word.text);
|
||||||
|
if (this.state.currentPartIndex < parts.length - 1) {
|
||||||
|
this.state.currentPartIndex += 1;
|
||||||
|
this.emitStateChange();
|
||||||
|
this.scheduleNextWord();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newIndex = this.state.currentIndex + 1;
|
const newIndex = this.state.currentIndex + 1;
|
||||||
|
|
||||||
if (newIndex >= this.state.words.length) {
|
if (newIndex >= this.state.words.length) {
|
||||||
@@ -553,6 +613,7 @@ export class RSVPController extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.state.currentIndex = newIndex;
|
this.state.currentIndex = newIndex;
|
||||||
|
this.state.currentPartIndex = 0;
|
||||||
this.emitStateChange();
|
this.emitStateChange();
|
||||||
|
|
||||||
this.scheduleNextWord();
|
this.scheduleNextWord();
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ export interface RsvpState {
|
|||||||
playing: boolean;
|
playing: boolean;
|
||||||
words: RsvpWord[];
|
words: RsvpWord[];
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
|
currentPartIndex: number;
|
||||||
wpm: number;
|
wpm: number;
|
||||||
punctuationPauseMs: number;
|
punctuationPauseMs: number;
|
||||||
|
splitHyphens: boolean;
|
||||||
progress: number;
|
progress: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,24 @@ export function segmentCJKText(text: string): string[] {
|
|||||||
return words.filter((w) => w.trim().length > 0);
|
return words.filter((w) => w.trim().length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a hyphenated word into display parts, keeping a trailing hyphen on
|
||||||
|
* all but the last part. Only splits on hyphens that are directly between two
|
||||||
|
* letters (letter-hyphen-letter), so tokens like "--", "-word", "word-", and
|
||||||
|
* "foo--bar" are returned unchanged.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* "well-known" → ["well-", "known"]
|
||||||
|
* "a-b-c" → ["a-", "b-", "c"]
|
||||||
|
* "--" → ["--"]
|
||||||
|
* "hello" → ["hello"]
|
||||||
|
*/
|
||||||
|
export function getHyphenParts(word: string): string[] {
|
||||||
|
if (!/[a-zA-Z]-[a-zA-Z]/.test(word)) return [word];
|
||||||
|
const parts = word.split(/-(?=[a-zA-Z])/);
|
||||||
|
return parts.map((part, i) => (i < parts.length - 1 ? part + '-' : part));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split text into words, handling both CJK and non-CJK text
|
* Split text into words, handling both CJK and non-CJK text
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const validateAndNormalizeDate = (dateInput: string): ValidationResult<st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if year is reasonable (between 1000 and current year + 10)
|
// Check if year is reasonable (between 1000 and current year + 10)
|
||||||
const year = date.getFullYear();
|
const year = parseInt(cleaned.substring(0, 4));
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
if (year < 1000 || year > currentYear + 10) {
|
if (year < 1000 || year > currentYear + 10) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user