681e065ac4
* 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
478 lines
16 KiB
TypeScript
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;
|