Files
readest/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx
T
boludo00 681e065ac4 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
2026-02-05 18:19:13 +01:00

478 lines
16 KiB
TypeScript

'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useThemeStore } from '@/store/themeStore';
import { RSVPController, RsvpStartChoice, RsvpStopPosition } from '@/services/rsvp';
import { eventDispatcher } from '@/utils/event';
import { useTranslation } from '@/hooks/useTranslation';
import { BookNote } from '@/types/book';
import RSVPOverlay from './RSVPOverlay';
import RSVPStartDialog from './RSVPStartDialog';
interface RSVPControlProps {
bookKey: string;
}
// Helper to expand a range to include the full sentence
const expandRangeToSentence = (range: Range, doc: Document): Range => {
const sentenceRange = doc.createRange();
// Get the text content around the range
const container = range.commonAncestorContainer;
const parentElement =
container.nodeType === Node.TEXT_NODE ? container.parentElement : (container as Element);
if (!parentElement) return range;
// Get the full text of the parent paragraph/element
const fullText = parentElement.textContent || '';
const rangeText = range.toString();
// Find the position of our word in the parent text
const wordStart = fullText.indexOf(rangeText);
if (wordStart === -1) return range;
// Find sentence boundaries (. ! ? or start/end of text)
const sentenceEnders = /[.!?]/g;
let sentenceStart = 0;
let sentenceEnd = fullText.length;
// Find the sentence start (look backwards for sentence ender)
for (let i = wordStart - 1; i >= 0; i--) {
if (sentenceEnders.test(fullText[i]!)) {
sentenceStart = i + 1;
// Skip any whitespace after the sentence ender
while (sentenceStart < fullText.length && /\s/.test(fullText[sentenceStart]!)) {
sentenceStart++;
}
break;
}
}
// Find the sentence end (look forward for sentence ender)
for (let i = wordStart; i < fullText.length; i++) {
if (sentenceEnders.test(fullText[i]!)) {
sentenceEnd = i + 1;
break;
}
}
// Create a tree walker to find the text nodes
const walker = doc.createTreeWalker(parentElement, NodeFilter.SHOW_TEXT, null);
let currentOffset = 0;
let startNode: Text | null = null;
let startOffset = 0;
let endNode: Text | null = null;
let endOffset = 0;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const nodeLength = node.textContent?.length || 0;
if (!startNode && currentOffset + nodeLength > sentenceStart) {
startNode = node;
startOffset = sentenceStart - currentOffset;
}
if (currentOffset + nodeLength >= sentenceEnd) {
endNode = node;
endOffset = sentenceEnd - currentOffset;
break;
}
currentOffset += nodeLength;
}
if (startNode && endNode) {
try {
sentenceRange.setStart(startNode, Math.max(0, startOffset));
sentenceRange.setEnd(endNode, Math.min(endOffset, endNode.textContent?.length || 0));
return sentenceRange;
} catch {
return range;
}
}
return range;
};
const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey }) => {
const _ = useTranslation();
const {
getView,
getProgress,
getViewSettings: _getViewSettings,
setProgress: _setProgress,
} = useReaderStore();
const { getBookData } = useBookDataStore();
const { themeCode } = useThemeStore();
const [isActive, setIsActive] = useState(false);
const [showStartDialog, setShowStartDialog] = useState(false);
const [startChoice, setStartChoice] = useState<RsvpStartChoice | null>(null);
const controllerRef = useRef<RSVPController | null>(null);
const tempHighlightRef = useRef<BookNote | null>(null);
// Helper to remove any existing RSVP highlight
const removeRsvpHighlight = useCallback(() => {
const view = getView(bookKey);
if (tempHighlightRef.current && view) {
try {
view.addAnnotation(tempHighlightRef.current, true);
} catch {
// Ignore errors when removing
}
}
tempHighlightRef.current = null;
}, [bookKey, getView]);
// Clean up controller and highlight on unmount
useEffect(() => {
return () => {
if (controllerRef.current) {
// 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
removeRsvpHighlight();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Listen for RSVP start events
useEffect(() => {
const handleRSVPStart = (event: CustomEvent) => {
const { bookKey: rsvpBookKey, selectionText } = event.detail;
if (bookKey !== rsvpBookKey) return;
handleStart(selectionText);
};
const handleRSVPStop = (event: CustomEvent) => {
const { bookKey: rsvpBookKey } = event.detail;
if (bookKey !== rsvpBookKey) return;
handleClose();
};
eventDispatcher.on('rsvp-start', handleRSVPStart);
eventDispatcher.on('rsvp-stop', handleRSVPStop);
return () => {
eventDispatcher.off('rsvp-start', handleRSVPStart);
eventDispatcher.off('rsvp-stop', handleRSVPStop);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey]);
const handleStart = useCallback(
(selectionText?: string) => {
const view = getView(bookKey);
const bookData = getBookData(bookKey);
const progress = getProgress(bookKey);
if (!view || !bookData || !bookData.book) {
eventDispatcher.dispatch('toast', {
message: _('Unable to start RSVP'),
type: 'error',
});
return;
}
// Remove any existing RSVP highlight when starting new session
removeRsvpHighlight();
// Check if format is supported (not PDF)
if (bookData.book.format === 'PDF') {
eventDispatcher.dispatch('toast', {
message: _('RSVP not supported for PDF'),
type: 'warning',
});
return;
}
// Create controller if not exists
if (!controllerRef.current) {
controllerRef.current = new RSVPController(view, bookKey);
}
const controller = controllerRef.current;
// Set current CFI for position tracking
if (progress?.location) {
controller.setCurrentCfi(progress.location);
}
// Handle start choice event
const handleStartChoice = (e: Event) => {
const choice = (e as CustomEvent<RsvpStartChoice>).detail;
setStartChoice(choice);
// 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);
}
};
controller.addEventListener('rsvp-start-choice', handleStartChoice);
controller.requestStart(selectionText);
// Clean up listener after handling
setTimeout(() => {
controller.removeEventListener('rsvp-start-choice', handleStartChoice);
}, 100);
},
[_, bookKey, getBookData, getProgress, getView, removeRsvpHighlight],
);
const handleStartDialogSelect = useCallback(
(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': {
// 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;
}
},
[bookKey, getProgress, getView, startChoice],
);
const handleClose = useCallback(() => {
const controller = controllerRef.current;
const view = getView(bookKey);
if (controller && view) {
// Listen for the stop event to get the position
const handleRsvpStop = (e: Event) => {
const stopPosition = (e as CustomEvent<RsvpStopPosition | null>).detail;
if (stopPosition && stopPosition.cfi) {
try {
// Navigate to the word's CFI position
view.goTo(stopPosition.cfi);
// 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;
}
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;
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 (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(),
};
tempHighlightRef.current = highlight;
view.addAnnotation(highlight);
}
}
}
}
} catch (err) {
console.warn('Failed to sync RSVP position:', err);
}
}
};
controller.addEventListener('rsvp-stop', handleRsvpStop);
controller.stop();
controller.removeEventListener('rsvp-stop', handleRsvpStop);
} else if (controller) {
controller.stop();
}
setIsActive(false);
setShowStartDialog(false);
}, [bookKey, getView, removeRsvpHighlight, themeCode.primary]);
const handleChapterSelect = useCallback(
(href: string) => {
const view = getView(bookKey);
if (!view) return;
// Navigate to chapter
view.goTo(href);
// Wait for navigation, then reload RSVP content
setTimeout(() => {
const controller = controllerRef.current;
if (controller) {
const progress = getProgress(bookKey);
if (progress?.location) {
controller.setCurrentCfi(progress.location);
}
controller.loadNextPageContent();
}
}, 500);
},
[bookKey, getProgress, getView],
);
const handleRequestNextPage = useCallback(async () => {
const view = getView(bookKey);
if (!view) return;
// Remove RSVP highlight when moving to next page
removeRsvpHighlight();
// RSVP extracts ALL words from the current section via renderer.getContents().
// When RSVP runs out of words and calls this function, it means the entire
// chapter/section has been read, so we need to go to the next section.
await view.renderer.nextSection?.();
// Wait for section change, then load new content
setTimeout(() => {
const controller = controllerRef.current;
if (controller) {
const progress = getProgress(bookKey);
if (progress?.location) {
controller.setCurrentCfi(progress.location);
}
controller.loadNextPageContent();
}
}, 500);
}, [bookKey, getProgress, getView, removeRsvpHighlight]);
// Get current chapter info
const progress = getProgress(bookKey);
const bookData = getBookData(bookKey);
const chapters = bookData?.bookDoc?.toc || [];
const currentChapterHref = progress?.sectionHref || null;
// Use portal to render overlay at body level to avoid stacking context issues
const portalContainer = typeof document !== 'undefined' ? document.body : null;
return (
<>
{/* Start dialog - render via portal */}
{showStartDialog &&
startChoice &&
portalContainer &&
createPortal(
<RSVPStartDialog
startChoice={startChoice}
onSelect={handleStartDialogSelect}
onClose={() => setShowStartDialog(false)}
/>,
portalContainer,
)}
{/* RSVP Overlay - render via portal */}
{isActive &&
controllerRef.current &&
portalContainer &&
createPortal(
<RSVPOverlay
controller={controllerRef.current}
chapters={chapters}
currentChapterHref={currentChapterHref}
onClose={handleClose}
onChapterSelect={handleChapterSelect}
onRequestNextPage={handleRequestNextPage}
/>,
portalContainer,
)}
</>
);
};
export default RSVPControl;