diff --git a/packages/app-expo/src/screens/ReaderScreen.tsx b/packages/app-expo/src/screens/ReaderScreen.tsx index 97f05c13..9762e606 100644 --- a/packages/app-expo/src/screens/ReaderScreen.tsx +++ b/packages/app-expo/src/screens/ReaderScreen.tsx @@ -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([]); @@ -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({ @@ -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); @@ -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} diff --git a/packages/app-expo/src/screens/reader/ReaderTOCPanel.tsx b/packages/app-expo/src/screens/reader/ReaderTOCPanel.tsx index 4cc371f4..d7d138b7 100644 --- a/packages/app-expo/src/screens/reader/ReaderTOCPanel.tsx +++ b/packages/app-expo/src/screens/reader/ReaderTOCPanel.tsx @@ -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; @@ -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; @@ -47,6 +60,7 @@ export function ReaderTOCPanel({ toc, bookmarks, currentChapter, + currentHref, onClose, onTabChange, onSelectTocItem, @@ -58,14 +72,113 @@ export function ReaderTOCPanel({ const insets = useSafeAreaInsets(); const layout = useResponsiveLayout(); const { t, i18n } = useTranslation(); + const listRef = useRef>(null); + const tocS = useMemo(() => makeTocListStyles(colors), [colors]); + const activeKeys = useMemo( + () => findActiveTocKeys(toc, currentChapter, currentHref), + [toc, currentChapter, currentHref], + ); + const [expandedIds, setExpandedIds] = useState>(() => 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) => { + 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 ( + + {row.hasChildren ? ( + toggleExpanded(row.key)} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + {row.expanded ? ( + + ) : ( + + )} + + ) : ( + + )} + + {row.item.title} + + + ); + }, + [ + activeKeys.key, + colors.mutedForeground, + onSelectTocItem, + tocS.expandBtn, + tocS.expandPlaceholder, + tocS.item, + tocS.itemActive, + tocS.itemText, + tocS.itemTextActive, + toggleExpanded, + ], + ); return ( - + onTabChange("toc")} > @@ -130,21 +239,40 @@ export function ReaderTOCPanel({ {activeTab === "toc" ? ( - - {toc.length > 0 ? ( - toc.map((item) => ( - - )) - ) : ( - {t("reader.noToc", "暂无目录信息")} - )} - + rows.length > 0 ? ( + 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); + }} + /> + ) : ( + {t("reader.noToc", "暂无目录信息")} + ) ) : bookmarks.length > 0 ? ( {bookmarks.map((bm) => ( @@ -199,3 +327,95 @@ export function ReaderTOCPanel({ ); } + +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, + 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 }, + });