fix(rsvp): fix position restoration and keyboard handling (#3150)
* feat: add RSVP speed reading feature Implements Rapid Serial Visual Presentation (RSVP) speed reading mode: - Display words one at a time with ORP (Optimal Recognition Point) highlighting - Adjustable WPM speed (100-1000) with keyboard/button controls - Punctuation pause settings (25-200ms) - Progress tracking with chapter navigation - Context panel showing surrounding text when paused - Keyboard shortcuts (Space, Escape, arrows) and touch gestures - Chapter selector for quick navigation - Respects current theme colors - Persists settings (WPM, pause duration, position) per book New files: - services/rsvp/RSVPController.ts - Main controller with playback logic - services/rsvp/types.ts - TypeScript interfaces - components/rsvp/RSVPOverlay.tsx - Full-screen RSVP reader UI - components/rsvp/RSVPControl.tsx - Integration component - components/rsvp/RSVPStartDialog.tsx - Start position selection Closes #3111 * test(rsvp): add Playwright e2e tests for RSVP feature - Set up Playwright test infrastructure with config - Add comprehensive e2e tests for RSVP speed reading: - Opening RSVP from View menu - Start dialog options - Play/pause toggle - Speed adjustment - Skip navigation - Keyboard shortcuts - Progress bar - Chapter navigation - Accessibility tests - Add test data attributes and ARIA labels to RSVPOverlay - Add test scripts to package.json * refactor: remove test files, unsure of use case for now * chore: remove Playwright e2e test scripts from package.json - Deleted e2e test scripts related to Playwright from package.json as they are no longer needed. - Removed Playwright as a dev dependency to streamline the project. * chore: sync pnpm-lock.yaml after removing @playwright/test * fix: lint errors and timezone-aware date formatting * fix(rsvp): restore correct position when exiting RSVP mode Fix bug where exiting RSVP after changing speed would navigate to wrong position (several pages backwards). The issue was that Range objects become stale if the document re-renders, and the position recovery used global word index instead of per-document index. - Add docWordIndex to track word position within each document - Add docTotalWords to RsvpStopPosition for accurate recovery - Validate Range before using it, recreate from current DOM if stale - Use same word extraction logic as RSVPController for consistency * fix(rsvp): prevent arrow keys from changing chapter dropdown Arrow keys during RSVP mode were inadvertently navigating the chapter dropdown instead of controlling RSVP speed. Fixed by using capture phase for keyboard events and stopping propagation to prevent native elements from receiving the events. * chore: remove playwright-mcp screenshots from repo * chore: update gitignore * fix(i18n): wrap all RSVP overlay strings with translation function Restore missing translation wrappers for all user-facing strings: - Close RSVP button labels and tooltips - Select Chapter dropdown - WPM display - Context panel header - Ready state text - Chapter Progress label - Words count and time remaining - Reading progress slider - Pause/punctuation label - Skip back/forward buttons - Play/Pause button - Speed control buttons * fix(rsvp): use CFI-based position tracking for accurate page positioning - Add CFI generation for each extracted word during RSVP processing - Implement CFI path and character offset comparison for precise position matching - Update startFromCurrentPosition to use CFI matching instead of word index - Add extractFullDocPathWithOffset to handle CFI character offsets - Simplify RSVPControl by removing unused helper functions - Remove word index from RsvpPosition in favor of CFI-only tracking * fix(ViewMenu): re-enable Speed Reading Mode in production environment * refactor(ViewMenu, RSVPController): clean up code formatting and improve readability * refactor(ViewMenu): remove unused IoSpeedometer import * refactor(RSVPController): streamline CFI handling and improve section comparison - Removed redundant logic and re-using CFI util functions for CFI operations * refactor(RsvpPosition): remove legacy wordIndex field for cleaner type definition
This commit is contained in:
+1
-1
@@ -8,7 +8,6 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
.playwright-mcp
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
@@ -46,3 +45,4 @@ fastlane/report.xml
|
||||
|
||||
# nix
|
||||
result*
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -285,14 +285,11 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
|
||||
disabled={bookData.isFixedLayout}
|
||||
/>
|
||||
|
||||
{/* Temporarily disabled for production, will be ready after the merge of PR #3150 */}
|
||||
{process.env.NODE_ENV !== 'production' && (
|
||||
<MenuItem
|
||||
label={_('Speed Reading Mode')}
|
||||
onClick={handleStartRSVP}
|
||||
disabled={bookData.isFixedLayout}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
label={_('Speed Reading Mode')}
|
||||
onClick={handleStartRSVP}
|
||||
disabled={bookData.isFixedLayout}
|
||||
/>
|
||||
|
||||
<hr aria-hidden='true' className='border-base-300 my-1' />
|
||||
|
||||
|
||||
@@ -133,7 +133,9 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey }) => {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (controllerRef.current) {
|
||||
controllerRef.current.shutdown();
|
||||
// Use stop() instead of shutdown() to preserve saved position across sessions
|
||||
// shutdown() clears localStorage which loses the user's reading progress
|
||||
controllerRef.current.stop();
|
||||
controllerRef.current = null;
|
||||
}
|
||||
// Remove any existing RSVP highlight when component unmounts
|
||||
@@ -209,13 +211,13 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey }) => {
|
||||
const choice = (e as CustomEvent<RsvpStartChoice>).detail;
|
||||
setStartChoice(choice);
|
||||
|
||||
// If there's only one option (beginning), start directly
|
||||
if (!choice.hasSavedPosition && !choice.hasSelection) {
|
||||
controller.startFromBeginning();
|
||||
setIsActive(true);
|
||||
} else {
|
||||
// Show dialog for user to choose
|
||||
// If there's a saved position or selection, show dialog for user to choose
|
||||
if (choice.hasSavedPosition || choice.hasSelection) {
|
||||
setShowStartDialog(true);
|
||||
} else {
|
||||
// No saved position or selection - start from current page position
|
||||
controller.startFromCurrentPosition();
|
||||
setIsActive(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -234,27 +236,74 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey }) => {
|
||||
(option: 'beginning' | 'saved' | 'current' | 'selection') => {
|
||||
setShowStartDialog(false);
|
||||
const controller = controllerRef.current;
|
||||
const view = getView(bookKey);
|
||||
if (!controller) return;
|
||||
|
||||
// Handler for when we need to navigate to a different section for resume
|
||||
const handleNavigateToResume = (e: Event) => {
|
||||
const { cfi } = (e as CustomEvent<{ cfi: string }>).detail;
|
||||
controller.removeEventListener('rsvp-navigate-to-resume', handleNavigateToResume);
|
||||
|
||||
if (view && cfi) {
|
||||
// Navigate to the saved position's section
|
||||
view.goTo(cfi);
|
||||
|
||||
// Wait for navigation, then reload and start RSVP
|
||||
setTimeout(() => {
|
||||
const progress = getProgress(bookKey);
|
||||
if (progress?.location) {
|
||||
controller.setCurrentCfi(progress.location);
|
||||
}
|
||||
controller.loadNextPageContent();
|
||||
// Small delay to ensure content is loaded
|
||||
setTimeout(() => {
|
||||
controller.start();
|
||||
setIsActive(true);
|
||||
}, 100);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
switch (option) {
|
||||
case 'beginning':
|
||||
controller.startFromBeginning();
|
||||
setIsActive(true);
|
||||
break;
|
||||
case 'saved':
|
||||
// Listen for navigation event in case saved position is in different section
|
||||
controller.addEventListener('rsvp-navigate-to-resume', handleNavigateToResume);
|
||||
controller.startFromSavedPosition();
|
||||
// If startFromSavedPosition started directly (same section), setIsActive
|
||||
// If it emitted navigate event, the handler above will setIsActive after navigation
|
||||
if (!controller.currentState.active) {
|
||||
// Navigation event was emitted, don't set active yet
|
||||
} else {
|
||||
setIsActive(true);
|
||||
}
|
||||
// Clean up listener after a timeout if not used
|
||||
setTimeout(() => {
|
||||
controller.removeEventListener('rsvp-navigate-to-resume', handleNavigateToResume);
|
||||
}, 1000);
|
||||
break;
|
||||
case 'current':
|
||||
case 'current': {
|
||||
// Refresh the CFI in case user scrolled since dialog opened
|
||||
const currentProgress = getProgress(bookKey);
|
||||
if (currentProgress?.location) {
|
||||
controller.setCurrentCfi(currentProgress.location);
|
||||
}
|
||||
controller.startFromCurrentPosition();
|
||||
setIsActive(true);
|
||||
break;
|
||||
}
|
||||
case 'selection':
|
||||
if (startChoice?.selectionText) {
|
||||
controller.startFromSelection(startChoice.selectionText);
|
||||
}
|
||||
setIsActive(true);
|
||||
break;
|
||||
}
|
||||
setIsActive(true);
|
||||
},
|
||||
[startChoice],
|
||||
[bookKey, getProgress, getView, startChoice],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
@@ -266,49 +315,54 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey }) => {
|
||||
const handleRsvpStop = (e: Event) => {
|
||||
const stopPosition = (e as CustomEvent<RsvpStopPosition | null>).detail;
|
||||
|
||||
if (stopPosition?.range && typeof stopPosition.docIndex === 'number') {
|
||||
if (stopPosition && stopPosition.cfi) {
|
||||
try {
|
||||
// Get the document from the renderer
|
||||
const contents = view.renderer.getContents?.();
|
||||
const content = contents?.find((c) => c.index === stopPosition.docIndex);
|
||||
const doc = content?.doc;
|
||||
// Navigate to the word's CFI position
|
||||
view.goTo(stopPosition.cfi);
|
||||
|
||||
if (doc && stopPosition.range) {
|
||||
// Expand the range to include the full sentence
|
||||
const sentenceRange = expandRangeToSentence(stopPosition.range, doc);
|
||||
// Try to create a sentence highlight using the stored Range
|
||||
if (typeof stopPosition.docIndex === 'number' && stopPosition.range) {
|
||||
// Check if the original range is still valid
|
||||
let rangeIsValid = false;
|
||||
try {
|
||||
const rangeText = stopPosition.range.toString();
|
||||
rangeIsValid = rangeText === stopPosition.text;
|
||||
} catch {
|
||||
rangeIsValid = false;
|
||||
}
|
||||
|
||||
// Get CFI for navigation - MUST get this BEFORE navigating
|
||||
const cfi = view.getCFI(stopPosition.docIndex, stopPosition.range);
|
||||
if (rangeIsValid) {
|
||||
// Get the document from the renderer
|
||||
const contents = view.renderer.getContents?.();
|
||||
const content = contents?.find((c) => c.index === stopPosition.docIndex);
|
||||
const doc = content?.doc;
|
||||
|
||||
// Get CFI for the sentence highlight - MUST get this BEFORE navigating
|
||||
// because goTo() may re-render the document, invalidating the Range objects
|
||||
const sentenceCfi = cfi ? view.getCFI(stopPosition.docIndex, sentenceRange) : null;
|
||||
const sentenceText = sentenceRange.toString();
|
||||
if (doc) {
|
||||
// Expand the range to include the full sentence
|
||||
const sentenceRange = expandRangeToSentence(stopPosition.range, doc);
|
||||
const sentenceCfi = view.getCFI(stopPosition.docIndex, sentenceRange);
|
||||
const sentenceText = sentenceRange.toString();
|
||||
|
||||
if (cfi) {
|
||||
// Navigate to the position
|
||||
view.goTo(cfi);
|
||||
if (sentenceCfi) {
|
||||
// Remove any previous RSVP highlight
|
||||
removeRsvpHighlight();
|
||||
|
||||
if (sentenceCfi) {
|
||||
// Remove any previous RSVP highlight
|
||||
removeRsvpHighlight();
|
||||
// Create a persistent highlight for the sentence
|
||||
const highlight: BookNote = {
|
||||
id: `rsvp-temp-${Date.now()}`,
|
||||
type: 'annotation',
|
||||
cfi: sentenceCfi,
|
||||
text: sentenceText,
|
||||
style: 'underline',
|
||||
color: themeCode.primary,
|
||||
note: '',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Create a persistent highlight for the sentence using theme accent color
|
||||
const highlight: BookNote = {
|
||||
id: `rsvp-temp-${Date.now()}`,
|
||||
type: 'annotation',
|
||||
cfi: sentenceCfi,
|
||||
text: sentenceText,
|
||||
style: 'underline',
|
||||
color: themeCode.primary, // Use theme accent color (same as ORP focal point)
|
||||
note: '',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
tempHighlightRef.current = highlight;
|
||||
view.addAnnotation(highlight);
|
||||
// Note: highlight persists until next page, reader close, or new RSVP session
|
||||
tempHighlightRef.current = highlight;
|
||||
view.addAnnotation(highlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
};
|
||||
}, [controller, onRequestNextPage]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
// Keyboard shortcuts - use capture phase to intercept before native elements
|
||||
useEffect(() => {
|
||||
const handleKeyboard = (event: KeyboardEvent) => {
|
||||
if (!state.active) return;
|
||||
@@ -101,14 +101,17 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
switch (event.key) {
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
controller.togglePlayPause();
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.shiftKey) {
|
||||
controller.skipBackward(15);
|
||||
} else {
|
||||
@@ -117,6 +120,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.shiftKey) {
|
||||
controller.skipForward(15);
|
||||
} else {
|
||||
@@ -125,17 +129,20 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
controller.increaseSpeed();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
controller.decreaseSpeed();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyboard);
|
||||
return () => document.removeEventListener('keydown', handleKeyboard);
|
||||
// Use capture phase to handle events before they reach dropdown/select elements
|
||||
document.addEventListener('keydown', handleKeyboard, { capture: true });
|
||||
return () => document.removeEventListener('keydown', handleKeyboard, { capture: true });
|
||||
}, [state.active, controller, onClose]);
|
||||
|
||||
// Word display helpers
|
||||
@@ -184,14 +191,14 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
|
||||
// Chapter helpers
|
||||
const getCurrentChapterLabel = useCallback((): string => {
|
||||
if (!currentChapterHref) return 'Select Chapter';
|
||||
if (!currentChapterHref) return _('Select Chapter');
|
||||
const normalizedCurrent = currentChapterHref.split('#')[0]?.replace(/^\//, '') || '';
|
||||
const chapter = flatChapters.find((c) => {
|
||||
const normalizedHref = c.href.split('#')[0]?.replace(/^\//, '') || '';
|
||||
return normalizedHref === normalizedCurrent;
|
||||
});
|
||||
return chapter?.label || 'Select Chapter';
|
||||
}, [currentChapterHref, flatChapters]);
|
||||
return chapter?.label || _('Select Chapter');
|
||||
}, [_, currentChapterHref, flatChapters]);
|
||||
|
||||
const isChapterActive = useCallback(
|
||||
(href: string): boolean => {
|
||||
@@ -283,7 +290,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
return (
|
||||
<div
|
||||
data-testid='rsvp-overlay'
|
||||
aria-label='RSVP Speed Reading Overlay'
|
||||
aria-label={_('RSVP Speed Reading Overlay')}
|
||||
className='fixed inset-0 z-[10000] flex select-none flex-col'
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
@@ -462,6 +469,8 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
className='relative h-2 cursor-pointer overflow-visible rounded bg-gray-500/30'
|
||||
onClick={handleProgressBarClick}
|
||||
onKeyDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.key === 'ArrowLeft') controller.skipBackward();
|
||||
else if (e.key === 'ArrowRight') controller.skipForward();
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { RsvpWord, RsvpState, RsvpPosition, RsvpStopPosition, RsvpStartChoice } from './types';
|
||||
import { containsCJK, splitTextIntoWords } from './utils';
|
||||
import { compare as compareCFI } from 'foliate-js/epubcfi.js';
|
||||
import { XCFI } from '@/utils/xcfi';
|
||||
|
||||
const DEFAULT_WPM = 300;
|
||||
const MIN_WPM = 100;
|
||||
@@ -15,6 +17,7 @@ const POSITION_KEY_PREFIX = 'readest_rsvp_pos_';
|
||||
export class RSVPController extends EventTarget {
|
||||
private view: FoliateView;
|
||||
private bookKey: string;
|
||||
private bookId: string; // Book hash without session suffix, for persistent storage
|
||||
private currentCfi: string | null = null;
|
||||
|
||||
private state: RsvpState = {
|
||||
@@ -37,6 +40,9 @@ export class RSVPController extends EventTarget {
|
||||
super();
|
||||
this.view = view;
|
||||
this.bookKey = bookKey;
|
||||
// Extract book ID (hash) from bookKey format: "{hash}-{sessionId}"
|
||||
// Use only the hash for persistent position storage across sessions
|
||||
this.bookId = bookKey.split('-')[0] || bookKey;
|
||||
this.loadSettings();
|
||||
}
|
||||
|
||||
@@ -120,7 +126,8 @@ export class RSVPController extends EventTarget {
|
||||
}
|
||||
|
||||
private loadPositionFromStorage(): RsvpPosition | null {
|
||||
const stored = localStorage.getItem(`${POSITION_KEY_PREFIX}${this.bookKey}`);
|
||||
// Use bookId (without session suffix) for persistent position across sessions
|
||||
const stored = localStorage.getItem(`${POSITION_KEY_PREFIX}${this.bookId}`);
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored) as RsvpPosition;
|
||||
@@ -132,37 +139,71 @@ export class RSVPController extends EventTarget {
|
||||
}
|
||||
|
||||
private savePositionToStorage(): void {
|
||||
if (!this.currentCfi) return;
|
||||
if (this.state.words.length === 0) return;
|
||||
|
||||
const currentWord = this.state.words[this.state.currentIndex];
|
||||
if (!currentWord) return;
|
||||
|
||||
// Use the word's CFI if available, otherwise fall back to section CFI
|
||||
const cfi = currentWord.cfi || this.currentCfi;
|
||||
if (!cfi) return;
|
||||
|
||||
const position: RsvpPosition = {
|
||||
cfi: this.currentCfi,
|
||||
wordIndex: this.state.currentIndex,
|
||||
wordText: this.state.words[this.state.currentIndex]?.text || '',
|
||||
cfi: cfi,
|
||||
wordText: currentWord.text,
|
||||
};
|
||||
localStorage.setItem(`${POSITION_KEY_PREFIX}${this.bookKey}`, JSON.stringify(position));
|
||||
// Use bookId (without session suffix) for persistent position across sessions
|
||||
localStorage.setItem(`${POSITION_KEY_PREFIX}${this.bookId}`, JSON.stringify(position));
|
||||
}
|
||||
|
||||
private clearPositionFromStorage(): void {
|
||||
localStorage.removeItem(`${POSITION_KEY_PREFIX}${this.bookKey}`);
|
||||
// Use bookId (without session suffix) for persistent position across sessions
|
||||
localStorage.removeItem(`${POSITION_KEY_PREFIX}${this.bookId}`);
|
||||
}
|
||||
|
||||
private extractBaseCfi(cfi: string): string {
|
||||
const inner = cfi.replace(/^epubcfi\(/, '').replace(/\)$/, '');
|
||||
const parts = inner.split(',');
|
||||
let basePath = parts[0]!;
|
||||
const match = basePath.match(/^(.*\][^\/]*)/);
|
||||
if (match) {
|
||||
basePath = match[1]!;
|
||||
private getSpineIndex(cfi: string): number {
|
||||
try {
|
||||
return XCFI.extractSpineIndex(cfi);
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
return basePath;
|
||||
}
|
||||
|
||||
private isSameSection(cfi1: string | null, cfi2: string | null): boolean {
|
||||
if (!cfi1 || !cfi2) return false;
|
||||
const base1 = this.extractBaseCfi(cfi1);
|
||||
const base2 = this.extractBaseCfi(cfi2);
|
||||
return base1 === base2;
|
||||
const spine1 = this.getSpineIndex(cfi1);
|
||||
const spine2 = this.getSpineIndex(cfi2);
|
||||
return spine1 >= 0 && spine1 === spine2;
|
||||
}
|
||||
|
||||
private findWordIndexByCfi(words: RsvpWord[], targetCfi: string): number {
|
||||
// First try exact CFI match
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
if (word?.cfi === targetCfi) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if target is in same section as any word
|
||||
const targetSpineIndex = this.getSpineIndex(targetCfi);
|
||||
if (targetSpineIndex < 0) return -1;
|
||||
|
||||
// Find the first word at or after the target position using CFI compare
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
if (!word?.cfi) continue;
|
||||
|
||||
// Must be in the same section
|
||||
if (this.getSpineIndex(word.cfi) !== targetSpineIndex) continue;
|
||||
|
||||
// Use compareCFI to find first word at or after target
|
||||
if (compareCFI(word.cfi, targetCfi) >= 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
start(retryCount = 0): void {
|
||||
@@ -183,11 +224,27 @@ export class RSVPController extends EventTarget {
|
||||
this.pendingStartWordIndex = null;
|
||||
} else {
|
||||
const savedPosition = this.loadPositionFromStorage();
|
||||
if (savedPosition && this.isSameSection(savedPosition.cfi, this.currentCfi)) {
|
||||
if (savedPosition.wordIndex < words.length) {
|
||||
if (words[savedPosition.wordIndex]?.text === savedPosition.wordText) {
|
||||
startIndex = savedPosition.wordIndex;
|
||||
resumedFromIndex = savedPosition.wordIndex;
|
||||
if (savedPosition) {
|
||||
// Try CFI-based position recovery first
|
||||
if (savedPosition.cfi) {
|
||||
const cfiIndex = this.findWordIndexByCfi(words, savedPosition.cfi);
|
||||
if (cfiIndex >= 0) {
|
||||
startIndex = cfiIndex;
|
||||
resumedFromIndex = cfiIndex;
|
||||
} else {
|
||||
// CFI not found, try text match as fallback
|
||||
const textMatchIndex = words.findIndex((w) => w.text === savedPosition.wordText);
|
||||
if (textMatchIndex >= 0) {
|
||||
startIndex = textMatchIndex;
|
||||
resumedFromIndex = textMatchIndex;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy position without CFI - try text match
|
||||
const textMatchIndex = words.findIndex((w) => w.text === savedPosition.wordText);
|
||||
if (textMatchIndex >= 0) {
|
||||
startIndex = textMatchIndex;
|
||||
resumedFromIndex = textMatchIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,16 +322,19 @@ export class RSVPController extends EventTarget {
|
||||
stop(): void {
|
||||
this.savePositionToStorage();
|
||||
|
||||
const stopPosition: RsvpStopPosition | null =
|
||||
this.state.words.length > 0
|
||||
? {
|
||||
wordIndex: this.state.currentIndex,
|
||||
totalWords: this.state.words.length,
|
||||
text: this.state.words[this.state.currentIndex]?.text || '',
|
||||
range: this.state.words[this.state.currentIndex]?.range,
|
||||
docIndex: this.state.words[this.state.currentIndex]?.docIndex,
|
||||
}
|
||||
: null;
|
||||
let stopPosition: RsvpStopPosition | null = null;
|
||||
if (this.state.words.length > 0) {
|
||||
const currentWord = this.state.words[this.state.currentIndex];
|
||||
|
||||
stopPosition = {
|
||||
wordIndex: this.state.currentIndex,
|
||||
totalWords: this.state.words.length,
|
||||
text: currentWord?.text || '',
|
||||
range: currentWord?.range,
|
||||
docIndex: currentWord?.docIndex,
|
||||
cfi: currentWord?.cfi,
|
||||
};
|
||||
}
|
||||
|
||||
this.dispatchEvent(new CustomEvent('rsvp-stop', { detail: stopPosition }));
|
||||
|
||||
@@ -293,20 +353,17 @@ export class RSVPController extends EventTarget {
|
||||
}
|
||||
|
||||
requestStart(selectionText?: string): void {
|
||||
const words = this.extractWordsWithRanges();
|
||||
const firstVisibleWordIndex = this.findFirstVisibleWordIndex(words);
|
||||
|
||||
const savedPosition = this.loadPositionFromStorage();
|
||||
const hasSavedPosition = !!(
|
||||
savedPosition && this.isSameSection(savedPosition.cfi, this.currentCfi)
|
||||
);
|
||||
// Show Resume option if we have a saved position with a valid CFI
|
||||
// We don't require it to be in the same section - user may want to resume
|
||||
// from where they left off even if they've navigated elsewhere
|
||||
const hasSavedPosition = !!savedPosition?.cfi;
|
||||
const hasSelection = !!selectionText && selectionText.trim().length > 0;
|
||||
|
||||
const startChoice: RsvpStartChoice = {
|
||||
hasSavedPosition,
|
||||
hasSelection,
|
||||
selectionText: selectionText?.trim(),
|
||||
firstVisibleWordIndex,
|
||||
};
|
||||
|
||||
this.dispatchEvent(new CustomEvent('rsvp-start-choice', { detail: startChoice }));
|
||||
@@ -319,6 +376,25 @@ export class RSVPController extends EventTarget {
|
||||
}
|
||||
|
||||
startFromSavedPosition(): void {
|
||||
const savedPosition = this.loadPositionFromStorage();
|
||||
if (!savedPosition?.cfi) {
|
||||
// No saved position, start from beginning
|
||||
this.start();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if saved position is in a different section
|
||||
if (!this.isSameSection(savedPosition.cfi, this.currentCfi)) {
|
||||
// Need to navigate to the saved section first
|
||||
// Emit event for React component to handle navigation
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('rsvp-navigate-to-resume', {
|
||||
detail: { cfi: savedPosition.cfi },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingStartWordIndex = null;
|
||||
this.start();
|
||||
}
|
||||
@@ -326,8 +402,17 @@ export class RSVPController extends EventTarget {
|
||||
startFromCurrentPosition(): void {
|
||||
this.clearPositionFromStorage();
|
||||
const words = this.extractWordsWithRanges();
|
||||
const firstVisibleIndex = this.findFirstVisibleWordIndex(words);
|
||||
this.pendingStartWordIndex = firstVisibleIndex > 0 ? firstVisibleIndex : null;
|
||||
|
||||
// Use CFI-based matching to find the first word at current page position
|
||||
let startIndex = 0;
|
||||
if (this.currentCfi) {
|
||||
const cfiIndex = this.findWordIndexByCfi(words, this.currentCfi);
|
||||
if (cfiIndex >= 0) {
|
||||
startIndex = cfiIndex;
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingStartWordIndex = startIndex > 0 ? startIndex : null;
|
||||
this.start();
|
||||
}
|
||||
|
||||
@@ -339,39 +424,6 @@ export class RSVPController extends EventTarget {
|
||||
this.start();
|
||||
}
|
||||
|
||||
private findFirstVisibleWordIndex(words: RsvpWord[]): number {
|
||||
if (words.length === 0) return 0;
|
||||
|
||||
try {
|
||||
const renderer = this.view.renderer;
|
||||
const scrollStart = renderer.start ?? 0;
|
||||
const pageSize = renderer.size ?? 0;
|
||||
|
||||
if (pageSize > 0) {
|
||||
const visibleStart = scrollStart - pageSize;
|
||||
const visibleEnd = scrollStart;
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
if (word?.range) {
|
||||
try {
|
||||
const rect = word.range.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.left >= visibleStart && rect.left < visibleEnd) {
|
||||
return i;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to return 0
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private findWordIndexBySelection(words: RsvpWord[], selectionText: string): number {
|
||||
if (!selectionText || words.length === 0) return -1;
|
||||
|
||||
@@ -588,7 +640,6 @@ export class RSVPController extends EventTarget {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || '';
|
||||
const nodeWords = splitTextIntoWords(text);
|
||||
console.log('Extracted words from text node:', nodeWords);
|
||||
|
||||
let offset = 0;
|
||||
for (const word of nodeWords) {
|
||||
@@ -600,12 +651,22 @@ export class RSVPController extends EventTarget {
|
||||
range.setStart(node, wordStart);
|
||||
range.setEnd(node, wordStart + word.length);
|
||||
|
||||
// Generate CFI for this word for position tracking
|
||||
let cfi: string | undefined;
|
||||
try {
|
||||
cfi = this.view.getCFI(docIndex, range);
|
||||
} catch {
|
||||
// CFI generation failed, will fall back to word index
|
||||
cfi = undefined;
|
||||
}
|
||||
|
||||
words.push({
|
||||
text: word,
|
||||
orpIndex: this.calculateORP(word),
|
||||
pauseMultiplier: this.getPauseMultiplier(word),
|
||||
range,
|
||||
docIndex,
|
||||
cfi,
|
||||
});
|
||||
} catch {
|
||||
words.push({
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface RsvpWord {
|
||||
pauseMultiplier: number;
|
||||
range?: Range;
|
||||
docIndex?: number;
|
||||
cfi?: string; // Canonical Fragment Identifier for precise position tracking
|
||||
}
|
||||
|
||||
export interface RsvpState {
|
||||
@@ -19,7 +20,6 @@ export interface RsvpState {
|
||||
|
||||
export interface RsvpPosition {
|
||||
cfi: string;
|
||||
wordIndex: number;
|
||||
wordText: string;
|
||||
}
|
||||
|
||||
@@ -29,11 +29,11 @@ export interface RsvpStopPosition {
|
||||
text: string;
|
||||
range?: Range;
|
||||
docIndex?: number;
|
||||
cfi?: string; // Canonical Fragment Identifier for the stop position
|
||||
}
|
||||
|
||||
export interface RsvpStartChoice {
|
||||
hasSavedPosition: boolean;
|
||||
hasSelection: boolean;
|
||||
selectionText?: string;
|
||||
firstVisibleWordIndex: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user