Refactor App into TS modules; add tsc typecheck
This commit is contained in:
+1
-1
@@ -17,6 +17,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+6
-1
@@ -5,7 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "tsc -p tsconfig.json --noEmit",
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -16,6 +18,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1216
File diff suppressed because it is too large
Load Diff
-1660
File diff suppressed because it is too large
Load Diff
+453
@@ -0,0 +1,453 @@
|
||||
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 { 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 [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 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(".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 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);
|
||||
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={() => setShowTextInput((prev) => !prev)}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileUpload={handleFileUpload}
|
||||
fileAccept=".epub,.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={text}
|
||||
onChangeText={(e) => setText(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Check, ChevronLeft, ChevronRight, Link } from "lucide-react";
|
||||
import type { KeyboardEventHandler, MouseEventHandler } from "react";
|
||||
import type { BookMetadata } from "../types";
|
||||
import { styles } from "../styles";
|
||||
import { PauseSolid, PlaySolid } from "./Icons";
|
||||
|
||||
type Props = {
|
||||
isPlaying: boolean;
|
||||
onTogglePlay: () => void;
|
||||
onBack10: () => void;
|
||||
onForward10: () => void;
|
||||
|
||||
onProgressClick: MouseEventHandler<HTMLDivElement>;
|
||||
onProgressKeyDown: KeyboardEventHandler<HTMLDivElement>;
|
||||
progressPercent: number;
|
||||
currentIndex: number;
|
||||
wordsLength: number;
|
||||
|
||||
linkCopied: boolean;
|
||||
onCopyLink: () => void;
|
||||
|
||||
bookMetadata: BookMetadata | null;
|
||||
bookStatsText: string | null;
|
||||
};
|
||||
|
||||
export function BottomControls({
|
||||
isPlaying,
|
||||
onTogglePlay,
|
||||
onBack10,
|
||||
onForward10,
|
||||
onProgressClick,
|
||||
onProgressKeyDown,
|
||||
progressPercent,
|
||||
currentIndex,
|
||||
wordsLength,
|
||||
linkCopied,
|
||||
onCopyLink,
|
||||
bookMetadata,
|
||||
bookStatsText,
|
||||
}: Props) {
|
||||
return (
|
||||
<div style={styles.bottomArea} className="bottom-area">
|
||||
<div style={styles.controlsRow}>
|
||||
<button onClick={onBack10} style={styles.skipBtn} title="Back 10 words">
|
||||
<ChevronLeft size={24} />
|
||||
<ChevronLeft size={24} style={{ marginLeft: -14 }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
style={styles.playBtn}
|
||||
className="play-btn"
|
||||
>
|
||||
{isPlaying ? <PauseSolid size={32} /> : <PlaySolid size={32} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onForward10}
|
||||
style={styles.skipBtn}
|
||||
title="Forward 10 words"
|
||||
>
|
||||
<ChevronRight size={24} />
|
||||
<ChevronRight size={24} style={{ marginLeft: -14 }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={styles.progressContainer}
|
||||
onClick={onProgressClick}
|
||||
onKeyDown={onProgressKeyDown}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label="Reading progress"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(progressPercent)}
|
||||
aria-valuetext={`${Math.round(progressPercent)}% complete, word ${currentIndex + 1} of ${wordsLength}`}
|
||||
>
|
||||
<div style={{ ...styles.progressBar, width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
<div style={styles.progressRow}>
|
||||
<div style={styles.progressText}>
|
||||
{currentIndex + 1} / {wordsLength} ({Math.round(progressPercent)}%)
|
||||
</div>
|
||||
<button
|
||||
onClick={onCopyLink}
|
||||
style={styles.linkBtn}
|
||||
className="icon-btn"
|
||||
title="Copy link to current position"
|
||||
>
|
||||
{linkCopied ? <Check size={14} /> : <Link size={14} />}
|
||||
<span style={styles.linkBtnText}>
|
||||
{linkCopied ? "Copied" : "Copy link"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={styles.hint} className="hint">
|
||||
<kbd style={styles.kbd}>Space</kbd> play<kbd style={styles.kbd}>←</kbd>
|
||||
<kbd style={styles.kbd}>→</kbd> word<kbd style={styles.kbd}>↑</kbd>
|
||||
<kbd style={styles.kbd}>↓</kbd> speed<kbd style={styles.kbd}>R</kbd>{" "}
|
||||
reset
|
||||
</div>
|
||||
|
||||
{bookMetadata && (bookMetadata.title || bookMetadata.cover) && (
|
||||
<aside
|
||||
style={styles.bookMetadata}
|
||||
aria-label="Current book"
|
||||
className="book-metadata"
|
||||
>
|
||||
{bookMetadata.cover && (
|
||||
<img
|
||||
src={bookMetadata.cover}
|
||||
alt={`Cover of ${bookMetadata.title || "current book"}`}
|
||||
style={styles.bookCover}
|
||||
className="book-cover"
|
||||
/>
|
||||
)}
|
||||
<div style={styles.bookInfo}>
|
||||
{bookMetadata.title && (
|
||||
<h3 style={styles.bookTitle} className="book-title">
|
||||
{bookMetadata.title}
|
||||
</h3>
|
||||
)}
|
||||
{bookMetadata.author && (
|
||||
<p style={styles.bookAuthor} className="book-author">
|
||||
{bookMetadata.author}
|
||||
</p>
|
||||
)}
|
||||
{bookStatsText && (
|
||||
<p style={styles.bookStats} className="book-stats">
|
||||
{bookStatsText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Share, X } from "lucide-react";
|
||||
import { styles } from "../styles";
|
||||
|
||||
type Props = {
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
export function IOSInstallBanner({ onDismiss }: Props) {
|
||||
return (
|
||||
<div style={styles.iosBanner}>
|
||||
<div style={styles.iosBannerContent}>
|
||||
<Share size={16} style={{ flexShrink: 0 }} />
|
||||
<span>For fullscreen, tap Share then "Add to Home Screen"</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
style={styles.iosBannerClose}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
type IconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function PlaySolid({ size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PauseSolid({ size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { styles } from "../styles";
|
||||
|
||||
type Props = {
|
||||
currentText: string;
|
||||
beforeORP: string;
|
||||
orpChar: string;
|
||||
afterORP: string;
|
||||
orpIndex: number;
|
||||
sideOpacity: number;
|
||||
};
|
||||
|
||||
export function ReaderDisplay({
|
||||
currentText,
|
||||
beforeORP,
|
||||
orpChar,
|
||||
afterORP,
|
||||
orpIndex,
|
||||
sideOpacity,
|
||||
}: Props) {
|
||||
return (
|
||||
<div style={styles.mainArea} className="main-area">
|
||||
<div style={styles.displayArea}>
|
||||
<div style={styles.focalGuide}>
|
||||
<div style={styles.focalLine} />
|
||||
<div style={styles.focalMarker} />
|
||||
<div style={styles.focalLine} />
|
||||
</div>
|
||||
|
||||
<div style={styles.wordContainer} className="word-container">
|
||||
{currentText ? (
|
||||
<div
|
||||
style={{
|
||||
...styles.wordDisplay,
|
||||
transform: `translateY(-50%) translateX(calc(-${orpIndex}ch - 0.5ch))`,
|
||||
}}
|
||||
className="mono word-display"
|
||||
>
|
||||
<span style={{ ...styles.beforeORP, opacity: sideOpacity }}>
|
||||
{beforeORP}
|
||||
</span>
|
||||
<span style={styles.orpChar}>{orpChar}</span>
|
||||
<span style={{ ...styles.afterORP, opacity: sideOpacity }}>
|
||||
{afterORP}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
...styles.wordDisplay,
|
||||
transform: "translateY(-50%) translateX(-50%)",
|
||||
}}
|
||||
className="mono word-display"
|
||||
>
|
||||
<span style={styles.placeholder}>Ready</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={styles.focalGuide}>
|
||||
<div style={styles.focalLine} />
|
||||
<div style={styles.focalMarker} />
|
||||
<div style={styles.focalLine} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ChangeEventHandler } from "react";
|
||||
import { styles } from "../styles";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
onChangeText: ChangeEventHandler<HTMLTextAreaElement>;
|
||||
};
|
||||
|
||||
export function TextInputOverlay({ text, onChangeText }: Props) {
|
||||
return (
|
||||
<div style={styles.textInputOverlay}>
|
||||
<div style={styles.textInputPanel}>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={onChangeText}
|
||||
style={styles.textarea}
|
||||
placeholder="Paste your text here..."
|
||||
rows={8}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { FileText, Info, Keyboard, Maximize, Minimize, Minus, Plus, Settings, Upload } from "lucide-react";
|
||||
import type { ChangeEventHandler, RefObject } from "react";
|
||||
import { styles } from "../styles";
|
||||
|
||||
type Props = {
|
||||
showTextInput: boolean;
|
||||
onToggleTextInput: () => void;
|
||||
|
||||
fileInputRef: RefObject<HTMLInputElement | null>;
|
||||
onFileUpload: ChangeEventHandler<HTMLInputElement>;
|
||||
fileAccept: string;
|
||||
isLoadingFile: boolean;
|
||||
|
||||
fullscreenSupported: boolean;
|
||||
isFullscreen: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
|
||||
wpm: number;
|
||||
onAdjustWpm: (delta: number) => void;
|
||||
|
||||
onToggleSettings: () => void;
|
||||
onToggleShortcuts: () => void;
|
||||
onToggleInfo: () => void;
|
||||
};
|
||||
|
||||
export function TopBar({
|
||||
showTextInput,
|
||||
onToggleTextInput,
|
||||
fileInputRef,
|
||||
onFileUpload,
|
||||
fileAccept,
|
||||
isLoadingFile,
|
||||
fullscreenSupported,
|
||||
isFullscreen,
|
||||
onToggleFullscreen,
|
||||
wpm,
|
||||
onAdjustWpm,
|
||||
onToggleSettings,
|
||||
onToggleShortcuts,
|
||||
onToggleInfo,
|
||||
}: Props) {
|
||||
return (
|
||||
<div style={styles.topBar} className="top-bar">
|
||||
<div style={styles.topLeft}>
|
||||
<button
|
||||
onClick={onToggleTextInput}
|
||||
style={{
|
||||
...styles.textBtn,
|
||||
...(showTextInput ? styles.textBtnActive : {}),
|
||||
}}
|
||||
className="icon-btn"
|
||||
title="Edit text"
|
||||
>
|
||||
<FileText size={16} />
|
||||
<span className="text-btn-label">Text</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={styles.textBtn}
|
||||
className="icon-btn"
|
||||
title="Upload file"
|
||||
disabled={isLoadingFile}
|
||||
>
|
||||
<Upload size={16} />
|
||||
<span className="text-btn-label">
|
||||
{isLoadingFile ? "Loading..." : "Upload"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={fileAccept}
|
||||
onChange={onFileUpload}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
{fullscreenSupported && (
|
||||
<button
|
||||
onClick={onToggleFullscreen}
|
||||
style={styles.textBtn}
|
||||
className="icon-btn"
|
||||
title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
|
||||
>
|
||||
{isFullscreen ? <Minimize size={16} /> : <Maximize size={16} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={styles.topCenter}>
|
||||
<div style={styles.wpmControl} className="wpm-control">
|
||||
<button
|
||||
onClick={() => onAdjustWpm(-25)}
|
||||
style={styles.wpmBtn}
|
||||
className="wpm-btn"
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<div style={styles.wpmDisplay}>
|
||||
<span style={styles.wpmValue} className="wpm-value">
|
||||
{wpm}
|
||||
</span>
|
||||
<span style={styles.wpmLabel}>WPM</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAdjustWpm(25)}
|
||||
style={styles.wpmBtn}
|
||||
className="wpm-btn"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.topRight}>
|
||||
<button
|
||||
onClick={onToggleSettings}
|
||||
style={styles.iconBtn}
|
||||
className="icon-btn"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleShortcuts}
|
||||
style={styles.iconBtn}
|
||||
className="icon-btn"
|
||||
title="Keyboard shortcuts"
|
||||
>
|
||||
<Keyboard size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleInfo}
|
||||
style={styles.iconBtn}
|
||||
className="icon-btn"
|
||||
title="How it works"
|
||||
>
|
||||
<Info size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { X } from "lucide-react";
|
||||
import { styles } from "../../styles";
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function InfoModal({ onClose }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={styles.modalOverlay}
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
style={styles.modal}
|
||||
className="modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="info-title"
|
||||
>
|
||||
<div style={styles.modalHeader}>
|
||||
<h2 id="info-title" style={styles.modalTitle}>
|
||||
How RSVP speed reading works
|
||||
</h2>
|
||||
<button onClick={onClose} style={styles.closeBtn}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={styles.modalContent}>
|
||||
<h3 style={styles.sectionTitle}>The science</h3>
|
||||
<p style={styles.paragraph}>
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/Rapid_serial_visual_presentation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={styles.link}
|
||||
>
|
||||
RSVP (Rapid Serial Visual Presentation)
|
||||
</a>{" "}
|
||||
displays text one word at a time at a fixed focal point. This
|
||||
eliminates eye movements (
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/Saccade"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={styles.link}
|
||||
>
|
||||
saccades
|
||||
</a>
|
||||
) that normally slow down reading - your eyes make 3-4 saccades per
|
||||
second during normal reading, each taking 20-30ms.
|
||||
</p>
|
||||
|
||||
<h3 style={styles.sectionTitle}>Optimal Recognition Point (ORP)</h3>
|
||||
<p style={styles.paragraph}>
|
||||
Research on the{" "}
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/Optimal_viewing_position"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={styles.link}
|
||||
>
|
||||
Optimal Viewing Position
|
||||
</a>{" "}
|
||||
shows that eyes naturally fixate slightly left of center when
|
||||
recognizing words - typically 20-35% from the beginning. The{" "}
|
||||
<span style={{ color: "#ff6b6b" }}>red letter</span> marks this
|
||||
point, staying fixed so your eyes never move.
|
||||
</p>
|
||||
|
||||
<h3 style={styles.sectionTitle}>Spritz ORP positioning</h3>
|
||||
<p style={styles.paragraph}>
|
||||
This reader uses the Spritz algorithm for ORP placement:
|
||||
</p>
|
||||
<ul style={styles.list}>
|
||||
<li>1 character: 1st letter</li>
|
||||
<li>2-5 characters: 2nd letter</li>
|
||||
<li>6-9 characters: 3rd letter</li>
|
||||
<li>10-13 characters: 4th letter</li>
|
||||
<li>14+ characters: 5th letter</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={styles.sectionTitle}>Research findings</h3>
|
||||
<p style={styles.paragraph}>
|
||||
Studies show RSVP can achieve 500+ WPM, though comprehension may
|
||||
decrease above 350-400 WPM for complex texts. Best for light
|
||||
reading, skimming, and building speed gradually.
|
||||
</p>
|
||||
|
||||
<h3 style={styles.sectionTitle}>Tips</h3>
|
||||
<ul style={styles.list}>
|
||||
<li>Start at 250-300 WPM and gradually increase</li>
|
||||
<li>Focus on the red letter, let words come to you</li>
|
||||
<li>Take breaks to avoid eye fatigue</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={styles.sectionTitle}>Source code</h3>
|
||||
<p style={styles.paragraph}>
|
||||
This project is open source and available on{" "}
|
||||
<a
|
||||
href="https://github.com/ronilaukkarinen/speed-reader"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={styles.link}
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Minus, Plus, X } from "lucide-react";
|
||||
import { styles } from "../../styles";
|
||||
|
||||
type Props = {
|
||||
wordAmount: number;
|
||||
sideOpacity: number;
|
||||
fetchMetadataOnline: boolean;
|
||||
onChangeWordAmount: (next: number) => void;
|
||||
onChangeSideOpacity: (next: number) => void;
|
||||
onToggleFetchMetadataOnline: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function SettingsModal({
|
||||
wordAmount,
|
||||
sideOpacity,
|
||||
fetchMetadataOnline,
|
||||
onChangeWordAmount,
|
||||
onChangeSideOpacity,
|
||||
onToggleFetchMetadataOnline,
|
||||
onClose,
|
||||
}: Props) {
|
||||
return (
|
||||
<div style={styles.modalOverlay} onClick={onClose} role="presentation">
|
||||
<div
|
||||
style={styles.modal}
|
||||
className="modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="settings-title"
|
||||
>
|
||||
<div style={styles.modalHeader}>
|
||||
<h2 id="settings-title" style={styles.modalTitle}>
|
||||
Settings
|
||||
</h2>
|
||||
<button onClick={onClose} style={styles.closeBtn}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: "20px" }}>
|
||||
<div style={styles.settingRow}>
|
||||
<label style={styles.settingLabel}>Words per display</label>
|
||||
<div style={styles.settingControl}>
|
||||
<button
|
||||
onClick={() => onChangeWordAmount(Math.max(1, wordAmount - 1))}
|
||||
style={styles.settingBtn}
|
||||
disabled={wordAmount <= 1}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span style={styles.settingValue}>{wordAmount}</span>
|
||||
<button
|
||||
onClick={() => onChangeWordAmount(Math.min(3, wordAmount + 1))}
|
||||
style={styles.settingBtn}
|
||||
disabled={wordAmount >= 3}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.settingRow}>
|
||||
<label style={styles.settingLabel}>Side opacity</label>
|
||||
<div style={styles.settingControl}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={sideOpacity * 100}
|
||||
onChange={(e) =>
|
||||
onChangeSideOpacity(Number(e.target.value) / 100)
|
||||
}
|
||||
style={styles.slider}
|
||||
/>
|
||||
<span style={styles.settingValue}>
|
||||
{Math.round(sideOpacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.settingRow}>
|
||||
<label style={styles.settingLabel}>
|
||||
<span>Fetch missing metadata online</span>
|
||||
<span style={styles.settingHint}>Uses Open Library API</span>
|
||||
</label>
|
||||
<div style={styles.settingControl}>
|
||||
<button
|
||||
onClick={onToggleFetchMetadataOnline}
|
||||
style={{
|
||||
...styles.toggleBtn,
|
||||
backgroundColor: fetchMetadataOnline ? "#ff6b6b" : "#333",
|
||||
}}
|
||||
aria-pressed={fetchMetadataOnline}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
...styles.toggleKnob,
|
||||
transform: fetchMetadataOnline
|
||||
? "translateX(16px)"
|
||||
: "translateX(0)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import { styles } from "../../styles";
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function ShortcutsModal({ onClose }: Props) {
|
||||
return (
|
||||
<div style={styles.modalOverlay} onClick={onClose} role="presentation">
|
||||
<div
|
||||
style={styles.modal}
|
||||
className="modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="shortcuts-title"
|
||||
>
|
||||
<div style={styles.modalHeader}>
|
||||
<h2 id="shortcuts-title" style={styles.modalTitle}>
|
||||
Keyboard shortcuts
|
||||
</h2>
|
||||
<button onClick={onClose} style={styles.closeBtn}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: "20px" }}>
|
||||
<div style={styles.shortcutList}>
|
||||
<div style={styles.shortcutRow}>
|
||||
<kbd style={styles.kbdLarge}>Space</kbd>
|
||||
<span>Play / Pause</span>
|
||||
</div>
|
||||
<div style={styles.shortcutRow}>
|
||||
<kbd style={styles.kbdLarge}>
|
||||
<ChevronLeft size={14} />
|
||||
</kbd>
|
||||
<span>Previous word</span>
|
||||
</div>
|
||||
<div style={styles.shortcutRow}>
|
||||
<kbd style={styles.kbdLarge}>
|
||||
<ChevronRight size={14} />
|
||||
</kbd>
|
||||
<span>Next word</span>
|
||||
</div>
|
||||
<div style={styles.shortcutRow}>
|
||||
<kbd style={styles.kbdLarge}>Shift + ←</kbd>
|
||||
<span>Back 10 words</span>
|
||||
</div>
|
||||
<div style={styles.shortcutRow}>
|
||||
<kbd style={styles.kbdLarge}>Shift + →</kbd>
|
||||
<span>Forward 10 words</span>
|
||||
</div>
|
||||
<div style={styles.shortcutRow}>
|
||||
<kbd style={styles.kbdLarge}>↑</kbd>
|
||||
<span>Increase speed</span>
|
||||
</div>
|
||||
<div style={styles.shortcutRow}>
|
||||
<kbd style={styles.kbdLarge}>↓</kbd>
|
||||
<span>Decrease speed</span>
|
||||
</div>
|
||||
<div style={styles.shortcutRow}>
|
||||
<kbd style={styles.kbdLarge}>R</kbd>
|
||||
<span>Reset to beginning</span>
|
||||
</div>
|
||||
<div style={styles.shortcutRow}>
|
||||
<kbd style={styles.kbdLarge}>Esc</kbd>
|
||||
<span>Close dialogs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import JSZip from "jszip";
|
||||
import type { BookMetadata, EpubParseResult } from "../types";
|
||||
|
||||
// Parse EPUB file and extract text + metadata.
|
||||
export async function parseEpub(file: File): Promise<EpubParseResult> {
|
||||
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[^>]*>([^<]+)<\/dc:title>/i);
|
||||
const authorMatch = opfContent.match(
|
||||
/<dc:creator[^>]*>([^<]+)<\/dc:creator>/i,
|
||||
);
|
||||
|
||||
const metadata: BookMetadata = {
|
||||
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(
|
||||
/<meta[^>]*name="cover"[^>]*content="([^"]+)"/i,
|
||||
);
|
||||
// Method 2: Look for item with properties="cover-image".
|
||||
const coverImageMatch = opfContent.match(
|
||||
/<item[^>]*properties="cover-image"[^>]*href="([^"]+)"/i,
|
||||
);
|
||||
// Method 3: Look for item with id containing "cover" and image media-type.
|
||||
const coverIdMatch = opfContent.match(
|
||||
/<item[^>]*id="[^"]*cover[^"]*"[^>]*href="([^"]+)"[^>]*media-type="image\/[^"]+"/i,
|
||||
);
|
||||
// Method 4: Alternate format for cover-image property.
|
||||
const coverImageMatch2 = opfContent.match(
|
||||
/<item[^>]*href="([^"]+)"[^>]*properties="cover-image"/i,
|
||||
);
|
||||
|
||||
let coverHref: string | null = 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(`<item[^>]*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(/<itemref[^>]*idref="([^"]+)"/g),
|
||||
];
|
||||
const manifestMatches = [
|
||||
...opfContent.matchAll(
|
||||
/<item[^>]*id="([^"]+)"[^>]*href="([^"]+)"[^>]*media-type="application\/xhtml\+xml"/g,
|
||||
),
|
||||
];
|
||||
|
||||
// Also try alternate manifest format.
|
||||
const manifestMatches2 = [
|
||||
...opfContent.matchAll(
|
||||
/<item[^>]*href="([^"]+)"[^>]*id="([^"]+)"[^>]*media-type="application\/xhtml\+xml"/g,
|
||||
),
|
||||
];
|
||||
|
||||
// Build manifest map.
|
||||
const manifest: Record<string, string> = {};
|
||||
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/html 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(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&#(\d+);/g, (_: string, n: string) =>
|
||||
String.fromCharCode(Number(n)),
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (textContent) {
|
||||
fullText += textContent + " ";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { text: fullText.trim(), metadata };
|
||||
}
|
||||
|
||||
// Fetch book metadata from Open Library API.
|
||||
export async function fetchMetadataFromOpenLibrary(
|
||||
title: string | null,
|
||||
author: string | null,
|
||||
): Promise<BookMetadata | null> {
|
||||
if (!title && !author) return null;
|
||||
|
||||
try {
|
||||
const query = [title, author].filter(Boolean).join(" ");
|
||||
const url = `https://openlibrary.org/search.json?q=${encodeURIComponent(query)}&limit=1&fields=title,author_name,cover_i`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data: unknown = await response.json();
|
||||
const docs = (data as { docs?: Array<Record<string, unknown>> }).docs;
|
||||
if (!docs || docs.length === 0) return null;
|
||||
|
||||
const book = docs[0] as {
|
||||
title?: string;
|
||||
author_name?: string[];
|
||||
cover_i?: number;
|
||||
};
|
||||
return {
|
||||
title: book.title || null,
|
||||
author: book.author_name?.[0] || null,
|
||||
cover: book.cover_i
|
||||
? `https://covers.openlibrary.org/b/id/${book.cover_i}-M.jpg`
|
||||
: null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch from Open Library:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Spritz ORP algorithm - position where the eye naturally fixates.
|
||||
// Based on Optimal Viewing Position research (20-35% from left).
|
||||
export function getORPIndex(wordLength: number): number {
|
||||
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
|
||||
}
|
||||
|
||||
export function getWordDelay(word: string, baseDelayMs: number): number {
|
||||
let multiplier = 1;
|
||||
multiplier += Math.sqrt(word.length) * 0.04;
|
||||
if (/[.!?]$/.test(word)) {
|
||||
multiplier = 2.5;
|
||||
} else if (/[,;:]$/.test(word)) {
|
||||
multiplier = 1.8;
|
||||
}
|
||||
return baseDelayMs * multiplier;
|
||||
}
|
||||
|
||||
export function formatReadingTime(minutes: number): string {
|
||||
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`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { PositionsMap, ReaderSettings } from "../types";
|
||||
|
||||
export const STORAGE_KEY = "rsvp-reader-settings";
|
||||
export const IOS_BANNER_DISMISSED_KEY = "rsvp-ios-banner-dismissed";
|
||||
|
||||
// Detect iOS Safari (not in standalone mode).
|
||||
export function isIOSSafari(): boolean {
|
||||
const ua = navigator.userAgent;
|
||||
const isIOS =
|
||||
/iPad|iPhone|iPod/.test(ua) ||
|
||||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
const isSafari = /Safari/.test(ua) && !/CriOS|FxiOS|OPiOS|mercury/.test(ua);
|
||||
const isStandalone = (window.navigator as unknown as { standalone?: boolean })
|
||||
.standalone === true;
|
||||
return isIOS && isSafari && !isStandalone;
|
||||
}
|
||||
|
||||
export function isFullscreenSupported(): boolean {
|
||||
return document.documentElement.requestFullscreen !== undefined;
|
||||
}
|
||||
|
||||
// Simple hash for text to use as key for positions.
|
||||
function hashText(text: string): string {
|
||||
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();
|
||||
}
|
||||
|
||||
export function loadSettings(): ReaderSettings | null {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (!saved) return null;
|
||||
|
||||
const parsed: unknown = JSON.parse(saved);
|
||||
// Keep validation light to avoid breaking older stored versions.
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
return parsed as ReaderSettings;
|
||||
} catch (e) {
|
||||
console.error("Failed to load settings:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(settings: ReaderSettings): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (e) {
|
||||
console.error("Failed to save settings:", e);
|
||||
}
|
||||
}
|
||||
|
||||
export function getPositionForText(text: string, positions?: PositionsMap): number {
|
||||
const hash = hashText(text);
|
||||
return positions?.[hash] ?? 0;
|
||||
}
|
||||
|
||||
export function savePositionForText(
|
||||
text: string,
|
||||
position: number,
|
||||
positions: PositionsMap,
|
||||
): PositionsMap {
|
||||
const hash = hashText(text);
|
||||
return { ...positions, [hash]: position };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
+522
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
export type PositionsMap = Record<string, number>;
|
||||
|
||||
export type BookMetadata = {
|
||||
title: string | null;
|
||||
author: string | null;
|
||||
cover: string | null;
|
||||
};
|
||||
|
||||
export type ReaderSettings = {
|
||||
wpm: number;
|
||||
text: string;
|
||||
positions: PositionsMap;
|
||||
sideOpacity: number;
|
||||
wordAmount: number;
|
||||
bookMetadata: BookMetadata | null;
|
||||
fetchMetadataOnline: boolean;
|
||||
};
|
||||
|
||||
export type EpubParseResult = {
|
||||
text: string;
|
||||
metadata: BookMetadata;
|
||||
};
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user