feat: implement mobile reader

- implement mobile epub reader
This commit is contained in:
2026-06-20 13:33:44 -05:00
parent 84c0497f21
commit 22ded87250
41 changed files with 3184 additions and 1111 deletions
@@ -0,0 +1,147 @@
import { type ReactNode } from "react";
import {
Text,
FlatList,
TouchableOpacity,
StyleSheet,
View,
} from "react-native";
import { resolvePalette } from "../../utils/epubTheme";
import type { ReadingSettings } from "../../types/reader";
import type { Bookmark } from "../../types";
import { ReaderBottomSheet } from "./ReaderBottomSheet";
interface BookmarksModalProps {
visible: boolean;
bookmarks: Bookmark[];
settings: ReadingSettings;
onSelect: (bookmark: Bookmark) => void;
onDelete: (bookmark: Bookmark) => void;
onClose: () => void;
}
export function BookmarksModal({
visible,
bookmarks,
settings,
onSelect,
onDelete,
onClose,
}: BookmarksModalProps): ReactNode {
const palette = resolvePalette(settings);
return (
<ReaderBottomSheet
visible={visible}
title="Bookmarks & highlights"
chromeColor={palette.chrome}
textColor={palette.text}
onClose={onClose}
>
<FlatList
data={bookmarks}
keyExtractor={(item) => item.id}
ListEmptyComponent={
<Text style={[styles.empty, { color: palette.text }]}>
No bookmarks yet. Tap the star to bookmark a page, or select text to
highlight it.
</Text>
}
renderItem={({ item }) => (
<View style={styles.row}>
<TouchableOpacity
style={styles.rowMain}
onPress={() => onSelect(item)}
>
{item.highlight_color ? (
<View
style={[
styles.dot,
{ backgroundColor: item.highlight_color },
]}
/>
) : (
<Text style={styles.star}></Text>
)}
<View style={styles.rowTextWrap}>
{item.chapter_title ? (
<Text
style={[styles.chapter, { color: palette.text }]}
numberOfLines={1}
>
{item.chapter_title}
</Text>
) : null}
<Text
style={[styles.excerpt, { color: palette.text }]}
numberOfLines={2}
>
{item.content || item.location_text || "Bookmarked page"}
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => onDelete(item)}
hitSlop={8}
style={styles.deleteButton}
>
<Text style={styles.deleteText}>Delete</Text>
</TouchableOpacity>
</View>
)}
/>
</ReaderBottomSheet>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 16,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "rgba(127,127,127,0.2)",
},
rowMain: {
flex: 1,
flexDirection: "row",
alignItems: "center",
},
rowTextWrap: {
flex: 1,
marginLeft: 10,
},
star: {
fontSize: 16,
color: "#4f8ef7",
},
dot: {
width: 14,
height: 14,
borderRadius: 7,
},
chapter: {
fontSize: 12,
opacity: 0.7,
marginBottom: 2,
},
excerpt: {
fontSize: 14,
},
deleteButton: {
marginLeft: 12,
paddingHorizontal: 10,
paddingVertical: 6,
},
deleteText: {
color: "#ff453a",
fontSize: 13,
fontWeight: "600",
},
empty: {
padding: 24,
textAlign: "center",
opacity: 0.7,
},
});
@@ -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",
},
});
@@ -0,0 +1,92 @@
import { type ReactNode } from "react";
import {
Modal,
View,
Text,
TouchableOpacity,
StyleSheet,
type StyleProp,
type ViewStyle,
} from "react-native";
interface ReaderBottomSheetProps {
visible: boolean;
title: string;
chromeColor: string;
textColor: string;
onClose: () => void;
children: ReactNode;
/** When true, caps sheet height (TOC/bookmarks). Settings uses a full-width panel. */
tall?: boolean;
sheetStyle?: StyleProp<ViewStyle>;
}
export function ReaderBottomSheet({
visible,
title,
chromeColor,
textColor,
onClose,
children,
tall = true,
sheetStyle,
}: ReaderBottomSheetProps): ReactNode {
return (
<Modal
visible={visible}
animationType="slide"
transparent
onRequestClose={onClose}
>
<View style={styles.backdrop}>
<View
style={[
styles.sheet,
tall && styles.sheetTall,
{ backgroundColor: chromeColor },
sheetStyle,
]}
>
<View style={styles.header}>
<Text style={[styles.headerTitle, { color: textColor }]}>
{title}
</Text>
<TouchableOpacity onPress={onClose} hitSlop={12}>
<Text style={[styles.close, { color: textColor }]}></Text>
</TouchableOpacity>
</View>
{children}
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.4)",
justifyContent: "flex-end",
},
sheet: {
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingBottom: 24,
},
sheetTall: {
maxHeight: "75%",
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: 16,
},
headerTitle: {
fontSize: 16,
fontWeight: "700",
},
close: {
fontSize: 18,
},
});
@@ -0,0 +1,152 @@
import { type ReactNode } from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import { resolvePalette } from "../../utils/epubTheme";
import type { ReadingSettings } from "../../types/reader";
interface ReaderToolbarProps {
title: string;
chapterTitle: string;
progressPct: number;
settings: ReadingSettings;
isBookmarked: boolean;
onBack: () => void;
onToggleToc: () => void;
onToggleSettings: () => void;
onToggleBookmarks: () => void;
onToggleBookmarkHere: () => void;
}
export function ReaderToolbar({
title,
chapterTitle,
progressPct,
settings,
isBookmarked,
onBack,
onToggleToc,
onToggleSettings,
onToggleBookmarks,
onToggleBookmarkHere,
}: ReaderToolbarProps): ReactNode {
const palette = resolvePalette(settings);
return (
<View style={[styles.bar, { backgroundColor: palette.chrome }]}>
<View style={styles.row}>
<TouchableOpacity
onPress={onBack}
hitSlop={12}
style={styles.iconButton}
>
<Text style={[styles.icon, { color: palette.text }]}></Text>
</TouchableOpacity>
<View style={styles.titles}>
<Text
style={[styles.title, { color: palette.text }]}
numberOfLines={1}
>
{title}
</Text>
{chapterTitle ? (
<Text
style={[styles.chapter, { color: palette.text }]}
numberOfLines={1}
>
{chapterTitle}
</Text>
) : null}
</View>
<TouchableOpacity
onPress={onToggleBookmarkHere}
hitSlop={12}
style={styles.iconButton}
>
<Text style={[styles.icon, { color: palette.text }]}>
{isBookmarked ? "★" : "☆"}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onToggleBookmarks}
hitSlop={12}
style={styles.iconButton}
>
<Text style={[styles.iconSmall, { color: palette.text }]}></Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onToggleToc}
hitSlop={12}
style={styles.iconButton}
>
<Text style={[styles.iconSmall, { color: palette.text }]}></Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onToggleSettings}
hitSlop={12}
style={styles.iconButton}
>
<Text style={[styles.iconSmall, { color: palette.text }]}>Aa</Text>
</TouchableOpacity>
</View>
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
{ width: `${Math.min(100, Math.max(0, progressPct))}%` },
]}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
bar: {
paddingTop: 8,
paddingHorizontal: 8,
paddingBottom: 6,
},
row: {
flexDirection: "row",
alignItems: "center",
},
titles: {
flex: 1,
marginHorizontal: 8,
},
title: {
fontSize: 14,
fontWeight: "600",
},
chapter: {
fontSize: 11,
opacity: 0.7,
},
iconButton: {
paddingHorizontal: 8,
paddingVertical: 4,
minWidth: 28,
alignItems: "center",
},
icon: {
fontSize: 28,
lineHeight: 30,
},
iconSmall: {
fontSize: 17,
fontWeight: "600",
},
progressTrack: {
height: 3,
borderRadius: 2,
backgroundColor: "rgba(127,127,127,0.25)",
marginTop: 6,
overflow: "hidden",
},
progressFill: {
height: "100%",
backgroundColor: "#4f8ef7",
},
});
@@ -0,0 +1,249 @@
import { type ReactNode } from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
} from "react-native";
import { resolvePalette, THEME_PALETTES } from "../../utils/epubTheme";
import type {
FontFamily,
ReadingSettings,
ThemePreset,
} from "../../types/reader";
import { ReaderBottomSheet } from "./ReaderBottomSheet";
const THEMES: ThemePreset[] = ["light", "sepia", "paper", "dark"];
const FONTS: { value: FontFamily; label: string }[] = [
{ value: "serif", label: "Serif" },
{ value: "sans-serif", label: "Sans" },
{ value: "monospace", label: "Mono" },
];
const FONT_SIZE_MIN = 12;
const FONT_SIZE_MAX = 32;
const LINE_HEIGHT_MIN = 1.2;
const LINE_HEIGHT_MAX = 2.2;
interface ReadingSettingsModalProps {
visible: boolean;
settings: ReadingSettings;
onChange: (patch: Partial<ReadingSettings>) => void;
onClose: () => void;
}
export function ReadingSettingsModal({
visible,
settings,
onChange,
onClose,
}: ReadingSettingsModalProps): ReactNode {
const palette = resolvePalette(settings);
const adjustFontSize = (delta: number) => {
const next = Math.min(
FONT_SIZE_MAX,
Math.max(FONT_SIZE_MIN, settings.font_size + delta),
);
onChange({ font_size: next });
};
const adjustLineHeight = (delta: number) => {
const next = Math.min(
LINE_HEIGHT_MAX,
Math.max(
LINE_HEIGHT_MIN,
Math.round((settings.line_height + delta) * 10) / 10,
),
);
onChange({ line_height: next });
};
return (
<ReaderBottomSheet
visible={visible}
title="Display"
chromeColor={palette.chrome}
textColor={palette.text}
onClose={onClose}
tall={false}
sheetStyle={styles.sheetBody}
>
<Text style={[styles.sectionLabel, { color: palette.text }]}>Theme</Text>
<View style={styles.rowWrap}>
{THEMES.map((theme) => {
const p = THEME_PALETTES[theme];
const active = settings.theme === theme;
return (
<TouchableOpacity
key={theme}
onPress={() =>
onChange({
theme,
background_color: p.background,
text_color: p.text,
})
}
style={[
styles.themeSwatch,
{ backgroundColor: p.background },
active && styles.themeSwatchActive,
]}
>
<Text style={[styles.themeSwatchText, { color: p.text }]}>
Aa
</Text>
</TouchableOpacity>
);
})}
</View>
<Text style={[styles.sectionLabel, { color: palette.text }]}>Font</Text>
<View style={styles.rowWrap}>
{FONTS.map((font) => {
const active = settings.font_family === font.value;
return (
<TouchableOpacity
key={font.value}
onPress={() => onChange({ font_family: font.value })}
style={[styles.pill, active && styles.pillActive]}
>
<Text
style={[
styles.pillText,
{ color: active ? "#fff" : palette.text },
]}
>
{font.label}
</Text>
</TouchableOpacity>
);
})}
</View>
<Stepper
label="Font size"
value={`${settings.font_size}px`}
color={palette.text}
onDecrease={() => adjustFontSize(-1)}
onIncrease={() => adjustFontSize(1)}
/>
<Stepper
label="Line spacing"
value={settings.line_height.toFixed(1)}
color={palette.text}
onDecrease={() => adjustLineHeight(-0.1)}
onIncrease={() => adjustLineHeight(0.1)}
/>
</ReaderBottomSheet>
);
}
function Stepper({
label,
value,
color,
onDecrease,
onIncrease,
}: {
label: string;
value: string;
color: string;
onDecrease: () => void;
onIncrease: () => void;
}): ReactNode {
return (
<View style={styles.stepperRow}>
<Text style={[styles.sectionLabel, { color, marginBottom: 0 }]}>
{label}
</Text>
<View style={styles.stepperControls}>
<TouchableOpacity style={styles.stepperButton} onPress={onDecrease}>
<Text style={styles.stepperButtonText}></Text>
</TouchableOpacity>
<Text style={[styles.stepperValue, { color }]}>{value}</Text>
<TouchableOpacity style={styles.stepperButton} onPress={onIncrease}>
<Text style={styles.stepperButtonText}>+</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
sheetBody: {
paddingHorizontal: 20,
paddingBottom: 32,
},
sectionLabel: {
fontSize: 13,
fontWeight: "600",
marginBottom: 8,
marginTop: 8,
opacity: 0.8,
},
rowWrap: {
flexDirection: "row",
flexWrap: "wrap",
gap: 10,
},
themeSwatch: {
width: 56,
height: 48,
borderRadius: 10,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "transparent",
},
themeSwatchActive: {
borderColor: "#4f8ef7",
},
themeSwatchText: {
fontSize: 16,
fontWeight: "600",
},
pill: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1,
borderColor: "rgba(127,127,127,0.4)",
},
pillActive: {
backgroundColor: "#4f8ef7",
borderColor: "#4f8ef7",
},
pillText: {
fontSize: 14,
fontWeight: "600",
},
stepperRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: 16,
},
stepperControls: {
flexDirection: "row",
alignItems: "center",
},
stepperButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "rgba(127,127,127,0.2)",
justifyContent: "center",
alignItems: "center",
},
stepperButtonText: {
fontSize: 20,
fontWeight: "700",
color: "#4f8ef7",
},
stepperValue: {
minWidth: 56,
textAlign: "center",
fontSize: 15,
fontWeight: "600",
},
});
+111
View File
@@ -0,0 +1,111 @@
import { type ReactNode } from "react";
import {
Text,
FlatList,
TouchableOpacity,
StyleSheet,
} from "react-native";
import { resolvePalette } from "../../utils/epubTheme";
import type { ReadingSettings } from "../../types/reader";
import { ReaderBottomSheet } from "./ReaderBottomSheet";
export interface TocItem {
id?: string;
label: string;
href: string;
subitems?: TocItem[];
}
interface FlatTocItem {
key: string;
label: string;
href: string;
depth: number;
}
function flatten(items: TocItem[], depth = 0, acc: FlatTocItem[] = []) {
items.forEach((item, index) => {
acc.push({
key: `${item.id ?? item.href}-${depth}-${index}`,
label: (item.label ?? "").trim() || "Untitled section",
href: item.href,
depth,
});
if (item.subitems?.length) {
flatten(item.subitems, depth + 1, acc);
}
});
return acc;
}
interface TocModalProps {
visible: boolean;
toc: TocItem[];
settings: ReadingSettings;
onSelect: (href: string) => void;
onClose: () => void;
}
export function TocModal({
visible,
toc,
settings,
onSelect,
onClose,
}: TocModalProps): ReactNode {
const palette = resolvePalette(settings);
const data = flatten(toc);
return (
<ReaderBottomSheet
visible={visible}
title="Contents"
chromeColor={palette.chrome}
textColor={palette.text}
onClose={onClose}
>
<FlatList
data={data}
keyExtractor={(item) => item.key}
ListEmptyComponent={
<Text style={[styles.empty, { color: palette.text }]}>
No table of contents available.
</Text>
}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.row}
onPress={() => onSelect(item.href)}
>
<Text
style={[
styles.rowText,
{ color: palette.text, marginLeft: item.depth * 16 },
]}
numberOfLines={2}
>
{item.label}
</Text>
</TouchableOpacity>
)}
/>
</ReaderBottomSheet>
);
}
const styles = StyleSheet.create({
row: {
paddingVertical: 12,
paddingHorizontal: 16,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "rgba(127,127,127,0.2)",
},
rowText: {
fontSize: 14,
},
empty: {
padding: 24,
textAlign: "center",
opacity: 0.7,
},
});