From d53f3b42e2ab0195f9fc1420c19e82d4df68e2c3 Mon Sep 17 00:00:00 2001 From: Lex Moulton Date: Sun, 5 Apr 2026 05:29:30 -0700 Subject: [PATCH] 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 --- .../src/__tests__/services/rsvp-utils.test.ts | 35 +++++++++ .../reader/components/rsvp/RSVPOverlay.tsx | 46 +++++------- .../src/services/rsvp/RSVPController.ts | 71 +++++++++++++++++-- apps/readest-app/src/services/rsvp/types.ts | 2 + apps/readest-app/src/services/rsvp/utils.ts | 18 +++++ apps/readest-app/src/utils/validation.ts | 2 +- 6 files changed, 141 insertions(+), 33 deletions(-) diff --git a/apps/readest-app/src/__tests__/services/rsvp-utils.test.ts b/apps/readest-app/src/__tests__/services/rsvp-utils.test.ts index 8f419535..69aea01b 100644 --- a/apps/readest-app/src/__tests__/services/rsvp-utils.test.ts +++ b/apps/readest-app/src/__tests__/services/rsvp-utils.test.ts @@ -6,6 +6,7 @@ import { getSegmenterLocale, segmentCJKText, splitTextIntoWords, + getHyphenParts, } from '@/services/rsvp/utils'; describe('rsvp/utils', () => { @@ -240,4 +241,38 @@ describe('rsvp/utils', () => { 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-']); + }); + }); }); diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx index 4beb9481..31858298 100644 --- a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx @@ -56,7 +56,7 @@ const RSVPOverlay: React.FC = ({ const _ = useTranslation(); const { themeCode, isDarkMode: _isDarkMode } = useThemeStore(); const [state, setState] = useState(controller.currentState); - const currentWord = state.words[state.currentIndex] ?? null; + const currentWord = controller.currentDisplayWord; const [countdown, setCountdown] = useState(controller.currentCountdown); const [showChapterDropdown, setShowChapterDropdown] = useState(false); const chapterDropdownRef = useRef(null); @@ -93,15 +93,10 @@ const RSVPOverlay: React.FC = ({ } return 0; }); - // Context window: only rebuild the rendered word list when the current word - // falls outside the window or nears its edge, keeping the DOM stable. - const [contextWindow, setContextWindow] = useState(() => { - 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 [contextWindow, setContextWindow] = useState(() => ({ + start: 0, + end: state.words.length, + })); const contextWordRef = useRef(null); const contextPanelRef = useRef(null); const touchStartX = useRef(0); @@ -134,25 +129,11 @@ const RSVPOverlay: React.FC = ({ const newState = (e as CustomEvent).detail; setState(newState); - // Update context window only when current word falls outside or nears edge - const idx = newState.currentIndex; + // Reset context window to show all words when the chapter changes const total = newState.words.length; setContextWindow((prev) => { - if (total === 0) return { start: 0, end: 0 }; - // Outside window — reset - 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; + if (total === prev.end && prev.start === 0) return prev; + return { start: 0, end: total }; }); }; @@ -851,6 +832,17 @@ const RSVPOverlay: React.FC = ({ + {/* Split hyphenated words */} +
+ {_('Split Hyphens')} + controller.setSplitHyphens(e.target.checked)} + /> +
+ {/* ORP color */}
{_('Focus')} diff --git a/apps/readest-app/src/services/rsvp/RSVPController.ts b/apps/readest-app/src/services/rsvp/RSVPController.ts index 6a1c03a6..66a4eb7e 100644 --- a/apps/readest-app/src/services/rsvp/RSVPController.ts +++ b/apps/readest-app/src/services/rsvp/RSVPController.ts @@ -1,6 +1,6 @@ import { FoliateView } from '@/types/view'; 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 { XCFI } from '@/utils/xcfi'; @@ -10,9 +10,11 @@ const MAX_WPM = 1000; const WPM_STEP = 50; const DEFAULT_PUNCTUATION_PAUSE_MS = 100; 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 PUNCTUATION_PAUSE_KEY_PREFIX = 'readest_rsvp_pause_'; const POSITION_KEY_PREFIX = 'readest_rsvp_pos_'; +const SPLIT_HYPHENS_KEY = 'readest_rsvp_split_hyphens'; export class RSVPController extends EventTarget { private view: FoliateView; @@ -24,8 +26,10 @@ export class RSVPController extends EventTarget { playing: false, words: [], currentIndex: 0, + currentPartIndex: 0, wpm: DEFAULT_WPM, punctuationPauseMs: DEFAULT_PUNCTUATION_PAUSE_MS, + splitHyphens: DEFAULT_SPLIT_HYPHENS, progress: 0, }; @@ -52,6 +56,10 @@ export class RSVPController extends EventTarget { if (savedPause) { this.state.punctuationPauseMs = savedPause; } + const savedSplitHyphens = this.loadSplitHyphensFromStorage(); + if (savedSplitHyphens !== null) { + this.state.splitHyphens = savedSplitHyphens; + } } get currentState(): RsvpState { @@ -69,6 +77,16 @@ export class RSVPController extends EventTarget { 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 { return this.countdown; } @@ -130,6 +148,30 @@ export class RSVPController extends EventTarget { 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 { this.currentCfi = cfi; } @@ -208,6 +250,7 @@ export class RSVPController extends EventTarget { setTimeout(() => this.start(retryCount + 1), 150 * (retryCount + 1)); return; } + this.dispatchEvent(new CustomEvent('rsvp-request-next-page')); return; } @@ -277,7 +320,7 @@ export class RSVPController extends EventTarget { this.clearCountdown(); onComplete(); } - }, 800); + }, 500); } private clearCountdown(): void { @@ -323,6 +366,7 @@ export class RSVPController extends EventTarget { playing: false, words: [], currentIndex: 0, + currentPartIndex: 0, }; this.emitStateChange(); } @@ -475,11 +519,13 @@ export class RSVPController extends EventTarget { this.state.words.length - 1, this.state.currentIndex + count, ); + this.state.currentPartIndex = 0; this.emitStateChange(); } skipBackward(count: number = 10): void { this.state.currentIndex = Math.max(0, this.state.currentIndex - count); + this.state.currentPartIndex = 0; this.emitStateChange(); } @@ -487,12 +533,14 @@ export class RSVPController extends EventTarget { if (this.state.words.length === 0) return; 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.currentPartIndex = 0; this.emitStateChange(); } seekToIndex(index: number): void { if (this.state.words.length === 0) return; this.state.currentIndex = Math.max(0, Math.min(this.state.words.length - 1, index)); + this.state.currentPartIndex = 0; this.emitStateChange(); } @@ -504,7 +552,7 @@ export class RSVPController extends EventTarget { setTimeout(() => this.loadNextPageContent(retryCount + 1), 200 * (retryCount + 1)); return; } - this.pause(); + this.dispatchEvent(new CustomEvent('rsvp-request-next-page')); return; } @@ -514,6 +562,7 @@ export class RSVPController extends EventTarget { playing: false, words, currentIndex: 0, + currentPartIndex: 0, }; this.emitStateChange(); @@ -536,8 +585,8 @@ export class RSVPController extends EventTarget { return; } - const word = this.state.words[this.state.currentIndex]!; - const duration = this.getWordDisplayDuration(word, this.state.wpm); + const displayWord = this.currentDisplayWord!; + const duration = this.getWordDisplayDuration(displayWord, this.state.wpm); this.playbackTimer = setTimeout(() => { this.advanceToNextWord(); @@ -545,6 +594,17 @@ export class RSVPController extends EventTarget { } 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; if (newIndex >= this.state.words.length) { @@ -553,6 +613,7 @@ export class RSVPController extends EventTarget { } this.state.currentIndex = newIndex; + this.state.currentPartIndex = 0; this.emitStateChange(); this.scheduleNextWord(); diff --git a/apps/readest-app/src/services/rsvp/types.ts b/apps/readest-app/src/services/rsvp/types.ts index 4eed6df7..c369de76 100644 --- a/apps/readest-app/src/services/rsvp/types.ts +++ b/apps/readest-app/src/services/rsvp/types.ts @@ -12,8 +12,10 @@ export interface RsvpState { playing: boolean; words: RsvpWord[]; currentIndex: number; + currentPartIndex: number; wpm: number; punctuationPauseMs: number; + splitHyphens: boolean; progress: number; } diff --git a/apps/readest-app/src/services/rsvp/utils.ts b/apps/readest-app/src/services/rsvp/utils.ts index c4d7c30b..b8e5003d 100644 --- a/apps/readest-app/src/services/rsvp/utils.ts +++ b/apps/readest-app/src/services/rsvp/utils.ts @@ -167,6 +167,24 @@ export function segmentCJKText(text: string): string[] { 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 */ diff --git a/apps/readest-app/src/utils/validation.ts b/apps/readest-app/src/utils/validation.ts index 3f8ee0e2..86ac3342 100644 --- a/apps/readest-app/src/utils/validation.ts +++ b/apps/readest-app/src/utils/validation.ts @@ -90,7 +90,7 @@ export const validateAndNormalizeDate = (dateInput: string): ValidationResult currentYear + 10) { return {