Files
read/src/App.tsx
T

488 lines
16 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 [draftText, setDraftText] = 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 [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 toggleTextInput = useCallback(() => {
setShowTextInput((prev) => {
const next = !prev;
if (next) setDraftText(text);
return next;
});
}, [text]);
const applyDraftText = useCallback(() => {
setText(draftText);
setShowTextInput(false);
}, [draftText]);
const cancelDraftText = useCallback(() => {
setDraftText(text);
setShowTextInput(false);
}, [text]);
const togglePlay = useCallback(() => {
if (currentIndex >= words.length - 1) {
setCurrentIndex(0);
}
setIsPlaying((prev) => !prev);
}, [currentIndex, words.length]);
const reset = useCallback(() => {
setIsPlaying(false);
setCurrentIndex(0);
}, []);
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);
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,
);
const next: ReaderSettings = {
wpm,
text,
positions: positionsRef.current,
sideOpacity,
wordAmount,
bookMetadata,
fetchMetadataOnline,
};
saveSettings(next);
}, [
wpm,
text,
currentIndex,
sideOpacity,
wordAmount,
bookMetadata,
fetchMetadataOnline,
]);
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 = 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, getBaseDelay, wordAmount]);
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(25);
break;
case "ArrowDown":
e.preventDefault();
adjustWpm(-25);
break;
case "r":
case "R":
e.preventDefault();
reset();
break;
case "Escape":
setShowInfo(false);
setShowShortcuts(false);
setDraftText(text);
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 =
bookMetadata && words.length > 0
? `${formatReadingTime((words.length - currentIndex) / wpm)} left`
: null;
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}
onToggleSettings={() => setShowSettings((prev) => !prev)}
onToggleShortcuts={() => setShowShortcuts((prev) => !prev)}
onToggleInfo={() => setShowInfo((prev) => !prev)}
/>
{showTextInput && (
<TextInputOverlay
text={draftText}
onChangeText={(e) => setDraftText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
applyDraftText();
}
}}
isDirty={draftText !== text}
onApply={applyDraftText}
onCancel={cancelDraftText}
/>
)}
<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}
onChangeWordAmount={setWordAmount}
onChangeSideOpacity={setSideOpacity}
onToggleFetchMetadataOnline={() =>
setFetchMetadataOnline((prev) => !prev)
}
onClose={() => setShowSettings(false)}
/>
)}
{showIOSBanner && <IOSInstallBanner onDismiss={dismissIOSBanner} />}
</div>
);
}