Files
read/src/App.tsx
T

658 lines
22 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import type {
ChangeEvent,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
} from "react";
import { BottomControls } from "./components/BottomControls";
import { IOSInstallBanner } from "./components/IOSInstallBanner";
import { ReaderDisplay } from "./components/ReaderDisplay";
import { TextInputOverlay } from "./components/TextInputOverlay";
import { TopBar } from "./components/TopBar";
import { InfoModal } from "./components/modals/InfoModal";
import { SettingsModal } from "./components/modals/SettingsModal";
import { ShortcutsModal } from "./components/modals/ShortcutsModal";
import { parseEpub, fetchMetadataFromOpenLibrary } from "./lib/epub";
import { parsePdf } from "./lib/pdf";
import { formatReadingTime, getORPIndex, getWordDelay } from "./lib/reading";
import {
IOS_BANNER_DISMISSED_KEY,
getPositionForText,
isFullscreenSupported,
isIOSSafari,
loadSettings,
savePositionForText,
saveSettings,
} from "./lib/storage";
import { styles } from "./styles";
import type { BookMetadata, PositionsMap, ReaderSettings } from "./types";
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!`;
function splitWords(text: string): string[] {
return text
.trim()
.split(/\s+/)
.filter((w) => w.length > 0);
}
export default function App() {
// Load settings only once on mount.
const [savedSettings] = useState(() => loadSettings());
const positionsRef = useRef<PositionsMap>(savedSettings?.positions || {});
const [text, setText] = useState(() => savedSettings?.text || DEFAULT_TEXT);
const [words, setWords] = useState(() =>
splitWords(savedSettings?.text || DEFAULT_TEXT),
);
const [currentIndex, setCurrentIndex] = useState(() => {
// Check URL hash for shared position.
const hash = window.location.hash;
if (hash) {
const params = new URLSearchParams(hash.slice(1));
const urlPos = Number.parseInt(params.get("pos") || "", 10);
if (!Number.isNaN(urlPos) && urlPos >= 0) {
// Clear hash after reading.
window.history.replaceState(null, "", window.location.pathname);
const t = savedSettings?.text || DEFAULT_TEXT;
const wordCount = splitWords(t).length;
return Math.min(Math.max(0, urlPos), Math.max(0, wordCount - 1));
}
}
const t = savedSettings?.text || DEFAULT_TEXT;
const pos = getPositionForText(t, savedSettings?.positions || {});
const wordCount = splitWords(t).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<BookMetadata | null>(
() => savedSettings?.bookMetadata || null,
);
const [sideOpacity, setSideOpacity] = useState(
() => savedSettings?.sideOpacity ?? 0.5,
);
const [wordAmount, setWordAmount] = useState(
() => savedSettings?.wordAmount ?? 1,
);
const [fetchMetadataOnline, setFetchMetadataOnline] = useState(
() => savedSettings?.fetchMetadataOnline ?? false,
);
const [showRealWpm, setShowRealWpm] = useState(
() => savedSettings?.showRealWpm ?? false,
);
const [punctuationPauses, setPunctuationPauses] = useState(
() => savedSettings?.punctuationPauses ?? true,
);
const [realWpm, setRealWpm] = useState<number | null>(null);
const [linkCopied, setLinkCopied] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showIOSBanner, setShowIOSBanner] = useState(() => {
if (!isIOSSafari()) return false;
try {
return localStorage.getItem(IOS_BANNER_DISMISSED_KEY) !== "true";
} catch {
return true;
}
});
const timeoutRef = useRef<number | null>(null);
const prevTextRef = useRef(text);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [textEditorSession, setTextEditorSession] = useState(0);
const wpmEventsRef = useRef<Array<{ t: number; words: number }>>([]);
const lastIndexRef = useRef<number>(currentIndex);
const speedScaleRef = useRef(1);
const observedWpmRef = useRef<number | null>(null);
const observedWpmEmaRef = useRef<number | null>(null);
const lastControlTickRef = useRef<number | null>(null);
const wasPlayingRef = useRef(false);
const computeWindowStats = useCallback((now: number, windowMs: number) => {
const events = wpmEventsRef.current;
if (events.length === 0) return null;
const cutoff = now - windowMs;
let first = 0;
while (first < events.length && events[first].t < cutoff) first++;
if (first >= events.length) return null;
const spanMs = Math.max(1, now - events[first].t);
let wordsRead = 0;
for (let i = first; i < events.length; i++) wordsRead += events[i].words;
const wpmNow = Math.max(0, Math.round((wordsRead / (spanMs / 1000)) * 60));
return { wpm: wpmNow, wordsRead, spanMs };
}, []);
const pruneWpmEvents = useCallback((now: number, keepMs: number) => {
const cutoff = now - keepMs;
const events = wpmEventsRef.current;
let first = 0;
while (first < events.length && events[first].t < cutoff) first++;
if (first > 0) wpmEventsRef.current = events.slice(first);
}, []);
const toggleTextInput = useCallback(() => {
setShowTextInput((prev) => {
const next = !prev;
if (next) setTextEditorSession((s) => s + 1);
return next;
});
}, []);
const togglePlay = useCallback(() => {
if (currentIndex >= words.length - 1) {
setCurrentIndex(0);
}
setIsPlaying((prev) => !prev);
}, [currentIndex, words.length]);
const reset = useCallback(() => {
setIsPlaying(false);
setCurrentIndex(0);
wpmEventsRef.current = [];
lastIndexRef.current = 0;
speedScaleRef.current = 1;
observedWpmRef.current = null;
observedWpmEmaRef.current = null;
lastControlTickRef.current = null;
setRealWpm(null);
}, []);
const adjustWpm = useCallback((delta: number) => {
setWpm((prev) => Math.max(50, Math.min(1500, prev + delta)));
}, []);
const stepWord = useCallback((delta: number) => {
setCurrentIndex((prev) =>
Math.max(0, Math.min(words.length - 1, prev + delta)),
);
}, [words.length]);
const handleFileUpload = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
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);
let metadata = result.metadata;
// Fetch missing metadata from Open Library if enabled.
if (fetchMetadataOnline && (!metadata.title || !metadata.cover)) {
const onlineMetadata = await fetchMetadataFromOpenLibrary(
metadata.title,
metadata.author,
);
if (onlineMetadata) {
metadata = {
title: metadata.title || onlineMetadata.title,
author: metadata.author || onlineMetadata.author,
cover: metadata.cover || onlineMetadata.cover,
};
}
}
setBookMetadata(metadata);
} else if (file.name.endsWith(".pdf")) {
const extractedText = await parsePdf(file);
setText(extractedText);
setBookMetadata({ title: file.name, author: null, cover: null });
} else if (file.name.endsWith(".txt")) {
const textContent = await file.text();
setText(textContent);
setBookMetadata(null);
} else {
alert("Please upload an EPUB, PDF, or TXT file");
}
} catch (err) {
console.error("Error loading file:", err);
alert("Error loading file: " + (err as Error).message);
} finally {
setIsLoadingFile(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
},
[fetchMetadataOnline],
);
// Handle text changes (not on initial mount).
useEffect(() => {
if (text !== prevTextRef.current) {
const parsed = splitWords(text);
setWords(parsed);
// Text changed, load position for new text.
const savedPos = getPositionForText(text, positionsRef.current);
const nextIndex = Math.min(
Math.max(0, savedPos),
Math.max(0, parsed.length - 1),
);
setCurrentIndex(nextIndex);
prevTextRef.current = text;
setIsPlaying(false);
wpmEventsRef.current = [];
lastIndexRef.current = nextIndex;
speedScaleRef.current = 1;
observedWpmRef.current = null;
observedWpmEmaRef.current = null;
lastControlTickRef.current = null;
setRealWpm(null);
}
}, [text]);
// Save settings including position for current text.
useEffect(() => {
positionsRef.current = savePositionForText(
text,
currentIndex,
positionsRef.current,
);
const next: ReaderSettings = {
wpm,
text,
positions: positionsRef.current,
sideOpacity,
wordAmount,
bookMetadata,
fetchMetadataOnline,
showRealWpm,
punctuationPauses,
};
saveSettings(next);
}, [
wpm,
text,
currentIndex,
sideOpacity,
wordAmount,
bookMetadata,
fetchMetadataOnline,
showRealWpm,
punctuationPauses,
]);
const getTargetStepDelayMs = useCallback(() => {
// WPM is "real" words-per-minute, even when showing multiple words per display.
return (60_000 * wordAmount) / wpm;
}, [wpm, wordAmount]);
const estimateAverageMultiplier = useCallback(
(startIndex: number) => {
if (!punctuationPauses) return 1;
if (words.length === 0) return 1;
// Look ahead a bit to estimate typical slowdown so we can seed the scale quickly.
const steps = 120;
let sum = 0;
let count = 0;
for (let i = 0; i < steps; i++) {
const idx = startIndex + i * wordAmount;
if (idx >= words.length) break;
const end = Math.min(idx + wordAmount, words.length);
const chunk = words.slice(idx, end).join(" ");
// getWordDelay uses baseDelay * multiplier; pass base=1 to get multiplier back.
const m = getWordDelay(chunk, 1, true);
sum += m;
count++;
}
return count === 0 ? 1 : sum / count;
},
[punctuationPauses, wordAmount, words],
);
// Seed/Reset the scaling factor when starting playback or when timing settings change.
useEffect(() => {
const startedPlaying = isPlaying && !wasPlayingRef.current;
wasPlayingRef.current = isPlaying;
if (!startedPlaying) return;
const avgMult = estimateAverageMultiplier(currentIndex);
speedScaleRef.current = avgMult > 0 ? 1 / avgMult : 1;
wpmEventsRef.current = [];
observedWpmRef.current = null;
observedWpmEmaRef.current = null;
lastControlTickRef.current = null;
if (showRealWpm) setRealWpm(null);
}, [currentIndex, estimateAverageMultiplier, isPlaying, showRealWpm]);
useEffect(() => {
if (!isPlaying) return;
const idx = lastIndexRef.current;
const avgMult = estimateAverageMultiplier(idx);
speedScaleRef.current = avgMult > 0 ? 1 / avgMult : 1;
wpmEventsRef.current = [];
observedWpmRef.current = null;
observedWpmEmaRef.current = null;
lastControlTickRef.current = null;
if (showRealWpm) setRealWpm(null);
}, [estimateAverageMultiplier, isPlaying, punctuationPauses, showRealWpm, wordAmount]);
useEffect(() => {
if (isPlaying && words.length > 0 && currentIndex < words.length) {
const endIndex = Math.min(currentIndex + wordAmount, words.length);
const chunk = words.slice(currentIndex, endIndex).join(" ");
const baseDelay = getTargetStepDelayMs() * speedScaleRef.current;
const delay = getWordDelay(chunk, baseDelay, punctuationPauses);
timeoutRef.current = window.setTimeout(() => {
setCurrentIndex((prev) => {
if (prev + wordAmount >= words.length) {
setIsPlaying(false);
return prev;
}
return prev + wordAmount;
});
}, delay);
}
return () => {
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
};
}, [isPlaying, currentIndex, words, getTargetStepDelayMs, wordAmount, punctuationPauses]);
// Track forward progress for real WPM calculations.
useEffect(() => {
const prev = lastIndexRef.current;
lastIndexRef.current = currentIndex;
if (!isPlaying) return;
const delta = currentIndex - prev;
if (delta <= 0) return;
wpmEventsRef.current.push({ t: Date.now(), words: delta });
}, [currentIndex, isPlaying]);
// Control loop (every 1s):
// - Display "Real WPM" from a longer window for readability
// - Drive calibration from a shorter window + EWMA smoothing to avoid oscillation
useEffect(() => {
if (!isPlaying) return;
const tick = () => {
const now = Date.now();
pruneWpmEvents(now, 60_000);
const displayStats = computeWindowStats(now, 60_000);
const controlStats = computeWindowStats(now, 15_000);
if (showRealWpm) setRealWpm(displayStats?.wpm ?? null);
const prevTick = lastControlTickRef.current ?? now;
const dt = Math.max(1, now - prevTick);
lastControlTickRef.current = now;
if (controlStats && controlStats.spanMs >= 4000 && controlStats.wordsRead >= 12) {
const alpha = 1 - Math.exp(-dt / 12_000);
const prev = observedWpmEmaRef.current;
observedWpmEmaRef.current =
prev === null
? controlStats.wpm
: prev + alpha * (controlStats.wpm - prev);
}
const wpmForEta =
observedWpmEmaRef.current ?? displayStats?.wpm ?? null;
observedWpmRef.current = wpmForEta;
// Closed-loop: adjust timing so observed WPM approaches the requested WPM.
// Use a smaller/faster signal (EWMA of last ~15s) with a capped adjustment per tick.
const observed = observedWpmEmaRef.current;
if (punctuationPauses && observed !== null && observed > 0) {
const ratio = observed / wpm;
const rawAdj = Math.pow(ratio, 0.12);
const adj = Math.min(1.015, Math.max(0.985, rawAdj));
speedScaleRef.current = Math.min(
4,
Math.max(0.25, speedScaleRef.current * adj),
);
}
};
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [computeWindowStats, isPlaying, pruneWpmEvents, punctuationPauses, showRealWpm, wpm]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && (target.tagName === "TEXTAREA" || 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(e.shiftKey ? 100 : 25);
break;
case "ArrowDown":
e.preventDefault();
adjustWpm(e.shiftKey ? -100 : -25);
break;
case "r":
case "R":
e.preventDefault();
reset();
break;
case "Escape":
setShowInfo(false);
setShowShortcuts(false);
setShowTextInput(false);
setShowSettings(false);
break;
default:
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [adjustWpm, reset, togglePlay, words.length]);
const getCurrentWords = useCallback(() => {
if (words.length === 0) return "";
const endIndex = Math.min(currentIndex + wordAmount, words.length);
return words.slice(currentIndex, endIndex).join(" ");
}, [currentIndex, wordAmount, words]);
const copyPositionUrl = useCallback(async () => {
const url = `${window.location.origin}${window.location.pathname}#pos=${currentIndex}`;
try {
await navigator.clipboard.writeText(url);
setLinkCopied(true);
window.setTimeout(() => setLinkCopied(false), 2000);
} catch (e) {
console.error("Failed to copy URL:", e);
}
}, [currentIndex]);
const toggleFullscreen = useCallback(async () => {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
} else {
await document.exitFullscreen();
}
} catch (e) {
console.error("Fullscreen error:", e);
}
}, []);
const dismissIOSBanner = useCallback(() => {
setShowIOSBanner(false);
try {
localStorage.setItem(IOS_BANNER_DISMISSED_KEY, "true");
} catch {
// Ignore storage errors.
}
}, []);
// Listen for fullscreen changes.
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () =>
document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);
const handleProgressClick = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => {
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)));
},
[words.length],
);
const handleProgressKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
setCurrentIndex((prev) =>
Math.max(0, prev - Math.ceil(words.length / 100)),
);
} else if (e.key === "ArrowRight") {
e.preventDefault();
setCurrentIndex((prev) =>
Math.min(words.length - 1, prev + Math.ceil(words.length / 100)),
);
}
},
[words.length],
);
const progressPercent =
words.length > 0 ? ((currentIndex + 1) / words.length) * 100 : 0;
const currentText = getCurrentWords();
// For ORP, use the first word when displaying multiple words.
const firstWord = words[currentIndex] || "";
const orpIndex = getORPIndex(firstWord.length);
const beforeORP = currentText.slice(0, orpIndex);
const orpChar = currentText[orpIndex] || "";
const afterORP = currentText.slice(orpIndex + 1);
const bookStatsText = (() => {
if (!bookMetadata || words.length === 0) return null;
const wordsLeft = Math.max(0, words.length - currentIndex);
const effectiveWpm = observedWpmRef.current ?? wpm;
const minutesLeft = wordsLeft / Math.max(1, effectiveWpm);
return `${formatReadingTime(minutesLeft)} left`;
})();
return (
<div style={styles.container}>
<TopBar
showTextInput={showTextInput}
onToggleTextInput={toggleTextInput}
fileInputRef={fileInputRef}
onFileUpload={handleFileUpload}
fileAccept=".epub,.pdf,.txt"
isLoadingFile={isLoadingFile}
fullscreenSupported={isFullscreenSupported()}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
wpm={wpm}
onAdjustWpm={adjustWpm}
showRealWpm={showRealWpm}
realWpm={realWpm}
onToggleSettings={() => setShowSettings((prev) => !prev)}
onToggleShortcuts={() => setShowShortcuts((prev) => !prev)}
onToggleInfo={() => setShowInfo((prev) => !prev)}
/>
{showTextInput && (
<TextInputOverlay
key={textEditorSession}
initialText={text}
onApply={(nextText) => {
setText(nextText);
setShowTextInput(false);
}}
onCancel={() => setShowTextInput(false)}
/>
)}
<ReaderDisplay
currentText={currentText}
beforeORP={beforeORP}
orpChar={orpChar}
afterORP={afterORP}
orpIndex={orpIndex}
sideOpacity={sideOpacity}
/>
<BottomControls
isPlaying={isPlaying}
onTogglePlay={togglePlay}
onBack10={() => stepWord(-10)}
onForward10={() => stepWord(10)}
onProgressClick={handleProgressClick}
onProgressKeyDown={handleProgressKeyDown}
progressPercent={progressPercent}
currentIndex={currentIndex}
wordsLength={words.length}
linkCopied={linkCopied}
onCopyLink={copyPositionUrl}
bookMetadata={bookMetadata}
bookStatsText={bookStatsText}
/>
{showShortcuts && <ShortcutsModal onClose={() => setShowShortcuts(false)} />}
{showInfo && <InfoModal onClose={() => setShowInfo(false)} />}
{showSettings && (
<SettingsModal
wordAmount={wordAmount}
sideOpacity={sideOpacity}
fetchMetadataOnline={fetchMetadataOnline}
showRealWpm={showRealWpm}
punctuationPauses={punctuationPauses}
onChangeWordAmount={setWordAmount}
onChangeSideOpacity={setSideOpacity}
onToggleFetchMetadataOnline={() =>
setFetchMetadataOnline((prev) => !prev)
}
onToggleShowRealWpm={() => setShowRealWpm((prev) => !prev)}
onTogglePunctuationPauses={() =>
setPunctuationPauses((prev) => !prev)
}
onClose={() => setShowSettings(false)}
/>
)}
{showIOSBanner && <IOSInstallBanner onDismiss={dismissIOSBanner} />}
</div>
);
}