Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/app-expo/src/screens/ReaderScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export function ReaderScreen({ route, navigation }: Props) {
const [loadAttempt, setLoadAttempt] = useState(0);
const [progress, setProgress] = useState(0);
const [currentChapter, setCurrentChapter] = useState("");
const [currentTocHref, setCurrentTocHref] = useState("");
const [currentPage, setCurrentPage] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [toc, setToc] = useState<TOCItem[]>([]);
Expand Down Expand Up @@ -441,6 +442,7 @@ export function ReaderScreen({ route, navigation }: Props) {
useEffect(() => {
sessionProgressRef.current = null;
totalBookCharactersRef.current = null;
setCurrentTocHref("");
suppressProgressTracking(INITIAL_PROGRESS_RESTORE_GUARD_MS);
}, [bookId]);
const chapterTranslation = useChapterTranslation({
Expand Down Expand Up @@ -732,6 +734,7 @@ export function ReaderScreen({ route, navigation }: Props) {
sessionProgressRef.current = { mode: "page", current: detail.section.current };
}
if (detail.tocItem?.label) setCurrentChapter(detail.tocItem.label);
if (detail.tocItem?.href) setCurrentTocHref(detail.tocItem.href);
if (detail.cfi) {
if (lastCfiRef.current && detail.cfi !== lastCfiRef.current) {
const fractionDiff = Math.abs((detail.fraction ?? 0) - progress);
Expand Down Expand Up @@ -1986,6 +1989,7 @@ export function ReaderScreen({ route, navigation }: Props) {
toc={toc}
bookmarks={bookBookmarks}
currentChapter={currentChapter}
currentHref={currentTocHref}
onClose={() => setShowTOC(false)}
onTabChange={setTocActiveTab}
onSelectTocItem={goToTocItem}
Expand Down
286 changes: 253 additions & 33 deletions packages/app-expo/src/screens/reader/ReaderTOCPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,32 @@
import {
BookmarkFilledIcon,
BookmarkIcon,
ChevronDownIcon,
ChevronRightIcon,
Trash2Icon,
XIcon,
} from "@/components/ui/Icon";
import { useResponsiveLayout } from "@/hooks/use-responsive-layout";
import { useColors } from "@/styles/theme";
import { fontSize } from "@/styles/theme";
import { type ThemeColors, fontSize, fontWeight, radius, useColors } from "@/styles/theme";
import { getFirstTocHref } from "@readany/core/reader";
import type { TOCItem } from "@readany/core/types";
import { Modal, Pressable, ScrollView, Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
FlatList,
type ListRenderItemInfo,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SCREEN_HEIGHT } from "./reader-constants";
import { ListIcon } from "./reader-icons";
import { makeStyles } from "./reader-styles";
import { TOCTreeItem } from "./TOCTreeItem";
import { SCREEN_HEIGHT } from "./reader-constants";

export type Bookmark = {
id: string;
Expand All @@ -34,6 +46,7 @@ interface Props {
toc: TOCItem[];
bookmarks: Bookmark[];
currentChapter: string;
currentHref?: string;
onClose: () => void;
onTabChange: (tab: "toc" | "bookmarks") => void;
onSelectTocItem: (href: string) => void;
Expand All @@ -47,6 +60,7 @@ export function ReaderTOCPanel({
toc,
bookmarks,
currentChapter,
currentHref,
onClose,
onTabChange,
onSelectTocItem,
Expand All @@ -58,14 +72,113 @@ export function ReaderTOCPanel({
const insets = useSafeAreaInsets();
const layout = useResponsiveLayout();
const { t, i18n } = useTranslation();
const listRef = useRef<FlatList<FlatTocItem>>(null);
const tocS = useMemo(() => makeTocListStyles(colors), [colors]);
const activeKeys = useMemo(
() => findActiveTocKeys(toc, currentChapter, currentHref),
[toc, currentChapter, currentHref],
);
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(activeKeys.ancestors));
const rows = useMemo(
() => flattenVisibleToc(toc, expandedIds, activeKeys.key),
[toc, expandedIds, activeKeys.key],
);
const currentIndex = useMemo(
() => rows.findIndex((item) => item.key === activeKeys.key),
[rows, activeKeys.key],
);

useEffect(() => {
if (!visible || activeTab !== "toc") return;
setExpandedIds((current) => {
let changed = false;
const next = new Set(current);
for (const key of activeKeys.ancestors) {
if (!next.has(key)) {
next.add(key);
changed = true;
}
}
return changed ? next : current;
});
}, [activeKeys.ancestors, activeTab, visible]);

useEffect(() => {
if (!visible || activeTab !== "toc" || currentIndex < 0) return;
const timer = setTimeout(() => {
listRef.current?.scrollToIndex({
index: currentIndex,
animated: false,
viewPosition: currentIndex > 2 ? 0.25 : 0,
});
}, 80);
return () => clearTimeout(timer);
}, [activeTab, currentIndex, visible]);

const toggleExpanded = useCallback((key: string) => {
setExpandedIds((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);

const renderTocItem = useCallback(
({ item: row }: ListRenderItemInfo<FlatTocItem>) => {
const targetHref = getFirstTocHref(row.item);
const isCurrent = row.key === activeKeys.key;
const handlePress = () => {
if (targetHref) {
onSelectTocItem(targetHref);
return;
}
if (row.hasChildren) toggleExpanded(row.key);
};

return (
<TouchableOpacity
style={[tocS.item, { paddingLeft: 12 + row.level * 16 }, isCurrent && tocS.itemActive]}
onPress={handlePress}
activeOpacity={0.7}
>
{row.hasChildren ? (
<TouchableOpacity
style={tocS.expandBtn}
onPress={() => toggleExpanded(row.key)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
{row.expanded ? (
<ChevronDownIcon size={14} color={colors.mutedForeground} />
) : (
<ChevronRightIcon size={14} color={colors.mutedForeground} />
)}
</TouchableOpacity>
) : (
<View style={tocS.expandPlaceholder} />
)}
<Text style={[tocS.itemText, isCurrent && tocS.itemTextActive]} numberOfLines={1}>
{row.item.title}
</Text>
</TouchableOpacity>
);
},
[
activeKeys.key,
colors.mutedForeground,
onSelectTocItem,
tocS.expandBtn,
tocS.expandPlaceholder,
tocS.item,
tocS.itemActive,
tocS.itemText,
tocS.itemTextActive,
toggleExpanded,
],
);

return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={onClose}
>
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={s.modalBackdrop} onPress={onClose} />
<View
style={[
Expand All @@ -79,10 +192,7 @@ export function ReaderTOCPanel({
<View style={s.sheetHeader}>
<View style={s.tocTabBar}>
<TouchableOpacity
style={[
s.tocTab,
activeTab === "toc" && { backgroundColor: `${colors.primary}14` },
]}
style={[s.tocTab, activeTab === "toc" && { backgroundColor: `${colors.primary}14` }]}
onPress={() => onTabChange("toc")}
>
<ListIcon
Expand Down Expand Up @@ -114,8 +224,7 @@ export function ReaderTOCPanel({
style={[
s.tocTabText,
{
color:
activeTab === "bookmarks" ? colors.primary : colors.mutedForeground,
color: activeTab === "bookmarks" ? colors.primary : colors.mutedForeground,
},
]}
>
Expand All @@ -130,21 +239,40 @@ export function ReaderTOCPanel({
</View>

{activeTab === "toc" ? (
<ScrollView showsVerticalScrollIndicator={false} style={s.sheetScroll}>
{toc.length > 0 ? (
toc.map((item) => (
<TOCTreeItem
key={item.id || item.href}
item={item}
level={0}
currentChapter={currentChapter}
onSelect={onSelectTocItem}
/>
))
) : (
<Text style={s.sheetEmpty}>{t("reader.noToc", "暂无目录信息")}</Text>
)}
</ScrollView>
rows.length > 0 ? (
<FlatList
ref={listRef}
data={rows}
keyExtractor={(item) => item.key}
renderItem={renderTocItem}
style={s.sheetScroll}
showsVerticalScrollIndicator={false}
initialNumToRender={24}
maxToRenderPerBatch={24}
windowSize={9}
removeClippedSubviews
getItemLayout={(_, index) => ({
length: TOC_ROW_HEIGHT,
offset: TOC_ROW_HEIGHT * index,
index,
})}
onScrollToIndexFailed={(info) => {
listRef.current?.scrollToOffset({
offset: info.averageItemLength * info.index,
animated: false,
});
setTimeout(() => {
listRef.current?.scrollToIndex({
index: info.index,
animated: false,
viewPosition: info.index > 2 ? 0.25 : 0,
});
}, 80);
}}
/>
) : (
<Text style={s.sheetEmpty}>{t("reader.noToc", "暂无目录信息")}</Text>
)
) : bookmarks.length > 0 ? (
<ScrollView showsVerticalScrollIndicator={false} style={s.sheetScroll}>
{bookmarks.map((bm) => (
Expand Down Expand Up @@ -199,3 +327,95 @@ export function ReaderTOCPanel({
</Modal>
);
}

const TOC_ROW_HEIGHT = 44;

type FlatTocItem = {
key: string;
item: TOCItem;
level: number;
hasChildren: boolean;
expanded: boolean;
};

function normalizeHref(value?: string): string {
const raw = value || "";
try {
return decodeURIComponent(raw).replace(/^\.\//, "").replace(/#.*$/, "").trim();
} catch {
return raw.replace(/^\.\//, "").replace(/#.*$/, "").trim();
}
}

function tocItemKey(item: TOCItem, path: string): string {
return item.id || item.href || `${path}:${item.title}`;
}

function tocMatches(item: TOCItem, currentChapter: string, currentHref?: string): boolean {
const targetHref = normalizeHref(getFirstTocHref(item) || item.href);
const activeHref = normalizeHref(currentHref);
if (targetHref && activeHref && targetHref === activeHref) return true;
return !!currentChapter && item.title === currentChapter;
}

function findActiveTocKeys(
items: TOCItem[],
currentChapter: string,
currentHref?: string,
): { key: string | null; ancestors: string[] } {
type ActiveTocKeys = { key: string | null; ancestors: string[] };
const walk = (nodes: TOCItem[], ancestors: string[], pathPrefix: string): ActiveTocKeys => {
for (let index = 0; index < nodes.length; index += 1) {
const item = nodes[index];
const path = `${pathPrefix}.${index}`;
const key = tocItemKey(item, path);
if (tocMatches(item, currentChapter, currentHref)) {
return { key, ancestors };
}
const childMatch = walk(item.subitems ?? [], [...ancestors, key], path);
if (childMatch.key) return childMatch;
}
return { key: null, ancestors: [] };
};

return walk(items, [], "toc");
}

function flattenVisibleToc(
items: TOCItem[],
expandedIds: Set<string>,
activeKey: string | null,
): FlatTocItem[] {
const rows: FlatTocItem[] = [];
const walk = (nodes: TOCItem[], level: number, pathPrefix: string) => {
for (let index = 0; index < nodes.length; index += 1) {
const item = nodes[index];
const path = `${pathPrefix}.${index}`;
const key = tocItemKey(item, path);
const hasChildren = (item.subitems?.length ?? 0) > 0;
const expanded = hasChildren && (expandedIds.has(key) || key === activeKey);
rows.push({ key, item, level, hasChildren, expanded });
if (expanded && item.subitems) walk(item.subitems, level + 1, path);
}
};
walk(items, 0, "toc");
return rows;
}

const makeTocListStyles = (colors: ThemeColors) =>
StyleSheet.create({
item: {
height: TOC_ROW_HEIGHT,
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingVertical: 8,
paddingRight: 12,
borderRadius: radius.lg,
},
itemActive: { backgroundColor: `${colors.primary}18` },
expandBtn: { width: 20, height: 20, alignItems: "center", justifyContent: "center" },
expandPlaceholder: { width: 20 },
itemText: { fontSize: fontSize.sm, color: colors.foreground, flex: 1 },
itemTextActive: { color: colors.primary, fontWeight: fontWeight.medium },
});