Archived
437 lines
12 KiB
TypeScript
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",
|
|
},
|
|
});
|