This repository has been archived on 2026-07-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
cloud-reader/mobile/src/components/reader/EpubReaderView.tsx
T
crisleo94 22ded87250 feat: implement mobile reader
- implement mobile epub reader
2026-06-20 13:33:44 -05:00

437 lines
12 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import {
View,
Text,
ActivityIndicator,
StyleSheet,
type LayoutChangeEvent,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Reader, ReaderProvider, useReader } from "@epubjs-react-native/core";
import { useFileSystem } from "@epubjs-react-native/expo-file-system";
import * as FileSystem from "expo-file-system";
import type { EBookDetail } from "@cloud-reader/shared";
import { getAccessToken } from "../../api/client";
import { ebooksApi } from "../../api/ebooks";
import { getReadingProgress, updateReadingProgress } from "../../api/reader";
import {
fetchBookmarks,
createMarker,
deleteBookmark,
} from "../../api/annotations";
import { useReadingSettings } from "../../hooks/useReadingSettings";
import {
buildEpubTheme,
FONT_STACKS,
resolvePalette,
} from "../../utils/epubTheme";
import type { Bookmark } from "../../types";
import { ReaderToolbar } from "./ReaderToolbar";
import { TocModal, type TocItem } from "./TocModal";
import { ReadingSettingsModal } from "./ReadingSettingsModal";
import { BookmarksModal } from "./BookmarksModal";
const HIGHLIGHT_COLOR = "#ffd54a";
const PROGRESS_SAVE_MS = 800;
/** Loose view of the epubjs-react-native reader API we rely on. */
interface ReaderApi {
goToLocation?: (target: string) => void;
changeTheme?: (theme: Record<string, Record<string, string>>) => void;
changeFontSize?: (size: string) => void;
changeFontFamily?: (font: string) => void;
addAnnotation?: (
type: string,
cfiRange: string,
data?: unknown,
styles?: Record<string, unknown>,
) => void;
removeAnnotationByCfi?: (cfi: string) => void;
removeSelection?: () => void;
toc?: TocItem[];
}
interface EpubLocation {
start?: { cfi?: string };
}
interface EpubSection {
label?: string;
index?: number;
}
interface EpubReaderViewProps {
book: EBookDetail;
ebookId: number;
onClose: () => void;
}
export function EpubReaderView(props: EpubReaderViewProps): ReactNode {
return (
<ReaderProvider>
<EpubReaderInner {...props} />
</ReaderProvider>
);
}
function EpubReaderInner({
book,
ebookId,
onClose,
}: EpubReaderViewProps): ReactNode {
const reader = useReader() as unknown as ReaderApi;
const { settings, updateSettings } = useReadingSettings();
const [src, setSrc] = useState<string | null>(null);
const [initialLocation, setInitialLocation] = useState<string | undefined>();
const [fileError, setFileError] = useState(false);
const [ready, setReady] = useState(false);
const [size, setSize] = useState<{ width: number; height: number } | null>(
null,
);
const [progressPct, setProgressPct] = useState(
Math.round(book.progress?.current_position ?? 0),
);
const [chapterTitle, setChapterTitle] = useState("");
const [currentCfi, setCurrentCfi] = useState("");
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
const [tocOpen, setTocOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [bookmarksOpen, setBookmarksOpen] = useState(false);
const currentCfiRef = useRef("");
const progressPctRef = useRef(progressPct);
const sectionRef = useRef<{ index: number; label: string }>({
index: 0,
label: "",
});
const bookmarksRef = useRef<Bookmark[]>([]);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
bookmarksRef.current = bookmarks;
}, [bookmarks]);
// Download the EPUB with auth, and resolve the saved resume location.
useEffect(() => {
let active = true;
(async () => {
try {
const progress = await getReadingProgress(ebookId).catch(() => null);
if (active && progress?.epub_location) {
setInitialLocation(progress.epub_location);
currentCfiRef.current = progress.epub_location;
setCurrentCfi(progress.epub_location);
}
const token = await getAccessToken();
const url = ebooksApi.getFileUrl(ebookId);
const dest = `${FileSystem.cacheDirectory}ebook-${ebookId}.epub`;
const result = await FileSystem.downloadAsync(url, dest, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (!active) return;
if (result.status >= 400) {
setFileError(true);
return;
}
setSrc(result.uri);
} catch {
if (active) setFileError(true);
}
})();
return () => {
active = false;
};
}, [ebookId]);
// Load existing bookmarks/highlights for this book.
useEffect(() => {
let active = true;
fetchBookmarks(ebookId)
.then((res) => {
if (active) setBookmarks(res.results);
})
.catch(() => {
/* bookmarks are optional */
});
return () => {
active = false;
};
}, [ebookId]);
// Apply typography/theme to the rendition once it is ready and on changes.
useEffect(() => {
if (!ready) return;
try {
reader.changeTheme?.(buildEpubTheme(settings));
reader.changeFontSize?.(`${settings.font_size}px`);
reader.changeFontFamily?.(FONT_STACKS[settings.font_family]);
} catch {
/* ignore rendition styling errors */
}
}, [ready, settings, reader]);
const flushProgress = useCallback(() => {
const cfi = currentCfiRef.current;
if (!cfi) return;
updateReadingProgress(ebookId, {
percentage: progressPctRef.current,
epub_location: cfi,
current_chapter: sectionRef.current.index + 1,
}).catch(() => {
/* progress is best-effort */
});
}, [ebookId]);
// Flush the latest progress when leaving the reader.
useEffect(
() => () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
flushProgress();
},
[flushProgress],
);
const handleReady = useCallback(() => {
setReady(true);
bookmarksRef.current.forEach((bookmark) => {
if (bookmark.highlight_color && bookmark.epub_cfi) {
try {
reader.addAnnotation?.("highlight", bookmark.epub_cfi, undefined, {
fill: bookmark.highlight_color,
});
} catch {
/* highlight rendering is best-effort */
}
}
});
}, [reader]);
const handleLocationChange = useCallback(
(
_total: number,
location: EpubLocation,
progress: number,
section: EpubSection | null,
) => {
const cfi = location?.start?.cfi ?? "";
if (cfi) {
currentCfiRef.current = cfi;
setCurrentCfi(cfi);
}
const pct = Math.round(progress ?? 0);
progressPctRef.current = pct;
setProgressPct(pct);
const label = section?.label?.trim() ?? "";
sectionRef.current = {
index: section?.index ?? sectionRef.current.index,
label,
};
setChapterTitle(label);
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(flushProgress, PROGRESS_SAVE_MS);
},
[flushProgress],
);
const handleSelected = useCallback(
async (selectedText: string, cfiRange: string) => {
const excerpt = (selectedText ?? "").slice(0, 280);
try {
const created = await createMarker({
ebook: ebookId,
epub_cfi: cfiRange,
chapter_index: sectionRef.current.index,
chapter_title: sectionRef.current.label,
location_text: excerpt,
content: excerpt,
highlight_color: HIGHLIGHT_COLOR,
});
setBookmarks((prev) => [created, ...prev]);
try {
reader.addAnnotation?.("highlight", cfiRange, undefined, {
fill: HIGHLIGHT_COLOR,
});
reader.removeSelection?.();
} catch {
/* annotation rendering is best-effort */
}
} catch {
/* ignore highlight save failures */
}
},
[ebookId, reader],
);
const handleToggleBookmarkHere = useCallback(async () => {
const cfi = currentCfiRef.current;
if (!cfi) return;
const existing = bookmarksRef.current.find((b) => b.epub_cfi === cfi);
if (existing) {
setBookmarks((prev) => prev.filter((b) => b.id !== existing.id));
try {
reader.removeAnnotationByCfi?.(cfi);
} catch {
/* best-effort */
}
deleteBookmark(existing.id).catch(() => {});
return;
}
try {
const created = await createMarker({
ebook: ebookId,
epub_cfi: cfi,
chapter_index: sectionRef.current.index,
chapter_title: sectionRef.current.label,
location_text: sectionRef.current.label,
});
setBookmarks((prev) => [created, ...prev]);
} catch {
/* ignore bookmark save failures */
}
}, [ebookId, reader]);
const handleSelectBookmark = useCallback(
(bookmark: Bookmark) => {
setBookmarksOpen(false);
try {
reader.goToLocation?.(bookmark.epub_cfi);
} catch {
/* best-effort */
}
},
[reader],
);
const handleDeleteBookmark = useCallback(
(bookmark: Bookmark) => {
setBookmarks((prev) => prev.filter((b) => b.id !== bookmark.id));
try {
if (bookmark.highlight_color) {
reader.removeAnnotationByCfi?.(bookmark.epub_cfi);
}
} catch {
/* best-effort */
}
deleteBookmark(bookmark.id).catch(() => {});
},
[reader],
);
const handleTocSelect = useCallback(
(href: string) => {
setTocOpen(false);
try {
reader.goToLocation?.(href);
} catch {
/* best-effort */
}
},
[reader],
);
const onReaderLayout = useCallback((event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
setSize((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
}, []);
const palette = resolvePalette(settings);
const isBookmarked = bookmarks.some((b) => b.epub_cfi === currentCfi);
return (
<SafeAreaView
style={[styles.container, { backgroundColor: palette.background }]}
edges={["top", "bottom"]}
>
<ReaderToolbar
title={book.title}
chapterTitle={chapterTitle}
progressPct={progressPct}
settings={settings}
isBookmarked={isBookmarked}
onBack={onClose}
onToggleToc={() => setTocOpen(true)}
onToggleSettings={() => setSettingsOpen(true)}
onToggleBookmarks={() => setBookmarksOpen(true)}
onToggleBookmarkHere={handleToggleBookmarkHere}
/>
<View style={styles.readerArea} onLayout={onReaderLayout}>
{fileError ? (
<View style={styles.centered}>
<Text style={[styles.message, { color: palette.text }]}>
Could not download this book. Check your connection and try again.
</Text>
</View>
) : src && size ? (
<Reader
src={src}
fileSystem={useFileSystem}
width={size.width}
height={size.height}
initialLocation={initialLocation}
enableSelection
flow="paginated"
defaultTheme={buildEpubTheme(settings)}
onReady={handleReady}
onLocationChange={handleLocationChange}
onSelected={handleSelected}
/>
) : (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#4f8ef7" />
</View>
)}
</View>
<TocModal
visible={tocOpen}
toc={reader.toc ?? []}
settings={settings}
onSelect={handleTocSelect}
onClose={() => setTocOpen(false)}
/>
<ReadingSettingsModal
visible={settingsOpen}
settings={settings}
onChange={updateSettings}
onClose={() => setSettingsOpen(false)}
/>
<BookmarksModal
visible={bookmarksOpen}
bookmarks={bookmarks}
settings={settings}
onSelect={handleSelectBookmark}
onDelete={handleDeleteBookmark}
onClose={() => setBookmarksOpen(false)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
readerArea: {
flex: 1,
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
},
message: {
fontSize: 15,
textAlign: "center",
},
});