Archived
feat: implement mobile reader
- implement mobile epub reader
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
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",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user