import { useState, useEffect, useRef, useCallback } from "react"; import { Minus, Plus, Info, X, Keyboard, ChevronLeft, ChevronRight, FileText, Upload, Settings, } from "lucide-react"; import JSZip from "jszip"; // Solid play icon const PlaySolid = ({ size = 24 }) => ( ); // Solid pause icon const PauseSolid = ({ size = 24 }) => ( ); const DEFAULT_TEXT = `Welcome to the RSVP Speed Reader! This tool uses Rapid Serial Visual Presentation to help you read faster. Click the text icon in the top left to paste text or load an EPUB file. The reader displays one word at a time at a fixed focal point, reducing eye movement and allowing for faster reading speeds. Research suggests that RSVP can help readers achieve speeds of 500 words per minute or more with practice. Try starting at a comfortable pace and gradually increase the speed as you become more accustomed to the technique. Happy reading!`; const STORAGE_KEY = "rsvp-reader-settings"; // Simple hash for text to use as key for positions function hashText(text) { let hash = 0; const str = text.slice(0, 200); // Use first 200 chars for hash for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; } return hash.toString(); } function loadSettings() { try { const saved = localStorage.getItem(STORAGE_KEY); if (saved) return JSON.parse(saved); } catch (e) { console.error("Failed to load settings:", e); } return null; } function saveSettings(settings) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } catch (e) { console.error("Failed to save settings:", e); } } function getPositionForText(text, positions) { const hash = hashText(text); return positions?.[hash] || 0; } function savePositionForText(text, position, positions) { const hash = hashText(text); return { ...positions, [hash]: position }; } // Parse EPUB file and extract text and metadata async function parseEpub(file) { const zip = await JSZip.loadAsync(file); // Find the container.xml to get the content.opf path const containerXml = await zip.file("META-INF/container.xml")?.async("text"); if (!containerXml) throw new Error("Invalid EPUB: missing container.xml"); // Parse container.xml to find rootfile path const rootfileMatch = containerXml.match(/rootfile[^>]*full-path="([^"]+)"/); if (!rootfileMatch) throw new Error("Invalid EPUB: cannot find rootfile"); const opfPath = rootfileMatch[1]; const opfDir = opfPath.substring(0, opfPath.lastIndexOf("/") + 1); // Read the OPF file const opfContent = await zip.file(opfPath)?.async("text"); if (!opfContent) throw new Error("Invalid EPUB: cannot read OPF"); // Extract metadata const titleMatch = opfContent.match(/]*>([^<]+)<\/dc:title>/i); const authorMatch = opfContent.match(/]*>([^<]+)<\/dc:creator>/i); const metadata = { title: titleMatch ? titleMatch[1].trim() : null, author: authorMatch ? authorMatch[1].trim() : null, cover: null, }; // Find cover image - try multiple methods // Method 1: Look for meta cover element const metaCoverMatch = opfContent.match(/]*name="cover"[^>]*content="([^"]+)"/i); // Method 2: Look for item with properties="cover-image" const coverImageMatch = opfContent.match(/]*properties="cover-image"[^>]*href="([^"]+)"/i); // Method 3: Look for item with id containing "cover" and image media-type const coverIdMatch = opfContent.match(/]*id="[^"]*cover[^"]*"[^>]*href="([^"]+)"[^>]*media-type="image\/[^"]+"/i); // Method 4: Alternate format for cover-image property const coverImageMatch2 = opfContent.match(/]*href="([^"]+)"[^>]*properties="cover-image"/i); let coverHref = null; if (coverImageMatch) { coverHref = coverImageMatch[1]; } else if (coverImageMatch2) { coverHref = coverImageMatch2[1]; } else if (metaCoverMatch) { // Need to find the href for this id const coverId = metaCoverMatch[1]; const itemMatch = opfContent.match(new RegExp(`]*id="${coverId}"[^>]*href="([^"]+)"`, "i")); if (itemMatch) coverHref = itemMatch[1]; } else if (coverIdMatch) { coverHref = coverIdMatch[1]; } // Load cover image if found (as base64 data URL for persistence) if (coverHref) { const coverPath = coverHref.startsWith("/") ? coverHref.slice(1) : opfDir + coverHref; const coverFile = zip.file(coverPath); if (coverFile) { const coverBase64 = await coverFile.async("base64"); const mimeMatch = coverHref.match(/\.(jpe?g|png|gif|webp)$/i); const mimeType = mimeMatch ? `image/${mimeMatch[1].toLowerCase().replace("jpg", "jpeg")}` : "image/jpeg"; metadata.cover = `data:${mimeType};base64,${coverBase64}`; } } // Get spine items (reading order) const spineMatches = [ ...opfContent.matchAll(/]*idref="([^"]+)"/g), ]; const manifestMatches = [ ...opfContent.matchAll( /]*id="([^"]+)"[^>]*href="([^"]+)"[^>]*media-type="application\/xhtml\+xml"/g, ), ]; // Also try alternate manifest format const manifestMatches2 = [ ...opfContent.matchAll( /]*href="([^"]+)"[^>]*id="([^"]+)"[^>]*media-type="application\/xhtml\+xml"/g, ), ]; // Build manifest map const manifest = {}; manifestMatches.forEach((m) => { manifest[m[1]] = m[2]; }); manifestMatches2.forEach((m) => { manifest[m[2]] = m[1]; }); // Get ordered content files const contentFiles = spineMatches.map((m) => manifest[m[1]]).filter(Boolean); // If spine parsing failed, try to get all xhtml files if (contentFiles.length === 0) { const allFiles = Object.keys(zip.files).filter( (f) => f.endsWith(".xhtml") || f.endsWith(".html") || f.endsWith(".htm"), ); contentFiles.push(...allFiles); } // Extract text from each content file let fullText = ""; for (const href of contentFiles) { const filePath = href.startsWith("/") ? href.slice(1) : opfDir + href; const content = await zip.file(filePath)?.async("text"); if (content) { // Strip HTML tags and get text const textContent = content .replace(/]*>[\s\S]*?<\/script>/gi, "") .replace(/]*>[\s\S]*?<\/style>/gi, "") .replace(/<[^>]+>/g, " ") .replace(/ /g, " ") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(n)) .replace(/\s+/g, " ") .trim(); if (textContent) { fullText += textContent + " "; } } } return { text: fullText.trim(), metadata }; } // Spritz ORP algorithm - position where the eye naturally fixates // Based on Optimal Viewing Position research (20-35% from left) function getORPIndex(wordLength) { if (wordLength === 0) return 0; if (wordLength === 1) return 0; // 1 char: 1st letter if (wordLength <= 5) return 1; // 2-5 chars: 2nd letter if (wordLength <= 9) return 2; // 6-9 chars: 3rd letter if (wordLength <= 13) return 3; // 10-13 chars: 4th letter return 4; // 14+ chars: 5th letter } function getWordDelay(word, baseDelay) { let multiplier = 1; multiplier += Math.sqrt(word.length) * 0.04; if (/[.!?]$/.test(word)) { multiplier = 2.5; } else if (/[,;:]$/.test(word)) { multiplier = 1.8; } return baseDelay * multiplier; } // Format reading time in human-readable format function formatReadingTime(minutes) { if (minutes < 1) return "< 1 min"; if (minutes < 60) return `${Math.round(minutes)} min`; const hours = Math.floor(minutes / 60); const mins = Math.round(minutes % 60); if (mins === 0) return `${hours}h`; return `${hours}h ${mins}m`; } // Words per page (standard estimate) const WORDS_PER_PAGE = 250; function App() { // Load settings only once on mount const [savedSettings] = useState(() => loadSettings()); const positionsRef = useRef(savedSettings?.positions || {}); const [text, setText] = useState(() => savedSettings?.text || DEFAULT_TEXT); const [words, setWords] = useState(() => { const t = savedSettings?.text || DEFAULT_TEXT; return t .trim() .split(/\s+/) .filter((w) => w.length > 0); }); const [currentIndex, setCurrentIndex] = useState(() => { const t = savedSettings?.text || DEFAULT_TEXT; const pos = getPositionForText(t, savedSettings?.positions || {}); const wordCount = t .trim() .split(/\s+/) .filter((w) => w.length > 0).length; return Math.min(Math.max(0, pos), Math.max(0, wordCount - 1)); }); const [isPlaying, setIsPlaying] = useState(false); const [wpm, setWpm] = useState(() => savedSettings?.wpm || 300); const [showInfo, setShowInfo] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false); const [showTextInput, setShowTextInput] = useState(false); const [showSettings, setShowSettings] = useState(false); const [isLoadingFile, setIsLoadingFile] = useState(false); const [bookMetadata, setBookMetadata] = useState( () => savedSettings?.bookMetadata || null, ); const [sideOpacity, setSideOpacity] = useState( () => savedSettings?.sideOpacity ?? 0.5, ); const [wordAmount, setWordAmount] = useState( () => savedSettings?.wordAmount ?? 1, ); const timeoutRef = useRef(null); const prevTextRef = useRef(text); const fileInputRef = useRef(null); const handleFileUpload = async (e) => { const file = e.target.files?.[0]; if (!file) return; setIsLoadingFile(true); try { if (file.name.endsWith(".epub")) { const result = await parseEpub(file); setText(result.text); setBookMetadata(result.metadata); } else if (file.name.endsWith(".txt")) { const textContent = await file.text(); setText(textContent); setBookMetadata(null); } else { alert("Please upload an EPUB or TXT file"); } } catch (err) { console.error("Error loading file:", err); alert("Error loading file: " + err.message); } finally { setIsLoadingFile(false); if (fileInputRef.current) { fileInputRef.current.value = ""; } } }; // Handle text changes (not on initial mount) useEffect(() => { if (text !== prevTextRef.current) { const parsed = text .trim() .split(/\s+/) .filter((w) => w.length > 0); setWords(parsed); // Text changed, load position for new text const savedPos = getPositionForText(text, positionsRef.current); setCurrentIndex( Math.min(Math.max(0, savedPos), Math.max(0, parsed.length - 1)), ); prevTextRef.current = text; setIsPlaying(false); } }, [text]); // Save settings including position for current text useEffect(() => { positionsRef.current = savePositionForText( text, currentIndex, positionsRef.current, ); saveSettings({ wpm, text, positions: positionsRef.current, sideOpacity, wordAmount, bookMetadata, }); }, [wpm, text, currentIndex, sideOpacity, wordAmount, bookMetadata]); const getBaseDelay = useCallback(() => { return (60 / wpm) * 1000; }, [wpm]); useEffect(() => { if (isPlaying && words.length > 0 && currentIndex < words.length) { const currentWord = words[currentIndex]; const delay = getWordDelay(currentWord, getBaseDelay()); timeoutRef.current = setTimeout(() => { setCurrentIndex((prev) => { if (prev + 1 >= words.length) { setIsPlaying(false); return prev; } return prev + 1; }); }, delay); } return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, [isPlaying, currentIndex, words, getBaseDelay]); useEffect(() => { const handleKeyDown = (e) => { if (e.target.tagName === "TEXTAREA" || e.target.tagName === "INPUT") return; switch (e.key) { case " ": e.preventDefault(); togglePlay(); break; case "ArrowRight": e.preventDefault(); if (e.shiftKey) { setCurrentIndex((prev) => Math.min(words.length - 1, prev + 10)); } else { setCurrentIndex((prev) => Math.min(words.length - 1, prev + 1)); } break; case "ArrowLeft": e.preventDefault(); if (e.shiftKey) { setCurrentIndex((prev) => Math.max(0, prev - 10)); } else { setCurrentIndex((prev) => Math.max(0, prev - 1)); } break; case "ArrowUp": e.preventDefault(); adjustWpm(25); break; case "ArrowDown": e.preventDefault(); adjustWpm(-25); break; case "r": case "R": e.preventDefault(); reset(); break; case "Escape": setShowInfo(false); setShowShortcuts(false); setShowTextInput(false); break; default: break; } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isPlaying, words.length]); const getCurrentWord = () => { if (words.length === 0) return ""; return words[currentIndex] || ""; }; const togglePlay = () => { if (currentIndex >= words.length - 1) { setCurrentIndex(0); } setIsPlaying(!isPlaying); }; const reset = () => { setIsPlaying(false); setCurrentIndex(0); }; const adjustWpm = (delta) => { setWpm((prev) => Math.max(50, Math.min(1500, prev + delta))); }; const handleProgressClick = (e) => { const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left; const percentage = x / rect.width; const newIndex = Math.floor(percentage * words.length); setCurrentIndex(Math.max(0, Math.min(words.length - 1, newIndex))); }; const stepWord = (delta) => { setCurrentIndex((prev) => Math.max(0, Math.min(words.length - 1, prev + delta)), ); }; const progress = words.length > 0 ? ((currentIndex + 1) / words.length) * 100 : 0; const currentWord = getCurrentWord(); const orpIndex = getORPIndex(currentWord.length); const beforeORP = currentWord.slice(0, orpIndex); const orpChar = currentWord[orpIndex] || ""; const afterORP = currentWord.slice(orpIndex + 1); return (
{/* Top controls */}
{wpm} WPM
{/* Text input panel - fixed position overlay */} {showTextInput && (