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(/