From 85162d7413d511a9741695bdd344e97499b7d545 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Fri, 8 May 2026 15:16:41 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=B5=E5=AD=90=E4=B9=A6=E5=AE=A4=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=BB=9A=E5=8A=A8=E9=98=85=E8=AF=BB=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/books/read/page.tsx | 440 ++++++++++++++++++++++++++++++++++-- 1 file changed, 416 insertions(+), 24 deletions(-) diff --git a/src/app/books/read/page.tsx b/src/app/books/read/page.tsx index d4b72d6..f2a9b8b 100644 --- a/src/app/books/read/page.tsx +++ b/src/app/books/read/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ChevronUp, Gauge, Headphones, Loader2, Moon, Pause, Play, SkipBack, SkipForward, Square, Sun, Volume2, Waves, X } from 'lucide-react'; +import { ChevronRight, ChevronUp, Gauge, Headphones, Loader2, Moon, Pause, Play, SkipBack, SkipForward, Square, Sun, Volume2, Waves, X } from 'lucide-react'; import { useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -77,12 +77,14 @@ interface EpubRendition { } type ReaderTheme = 'light' | 'sepia' | 'dark'; +type ReaderMode = 'paginated' | 'scrolled'; type FileLoadState = 'preparing' | 'checking-cache' | 'downloading' | 'opening' | 'ready'; interface ReaderSettings { fontSize: number; lineHeight: number; theme: ReaderTheme; + mode: ReaderMode; } interface TtsChunk { @@ -100,14 +102,24 @@ interface TtsSettings { autoPlayNext: boolean; } +interface ScrolledReadingPosition { + href: string; + scrollTop: number; + scrollHeight: number; + clientHeight: number; + updatedAt: number; +} + type TtsStatus = 'idle' | 'loading' | 'playing' | 'paused' | 'error'; const SETTINGS_STORAGE_KEY = 'books_epub_reader_settings'; +const SCROLLED_POSITION_STORAGE_KEY = 'books_epub_scrolled_positions'; const TTS_SETTINGS_STORAGE_KEY = 'books_epub_tts_settings'; const DEFAULT_SETTINGS: ReaderSettings = { fontSize: 100, lineHeight: 1.7, theme: 'light', + mode: 'paginated', }; const DEFAULT_TTS_SETTINGS: TtsSettings = { voice: '', @@ -196,6 +208,108 @@ function loadReaderSettings(): ReaderSettings { } } + +function buildScrolledPositionKey(sourceId: string, bookId: string, href?: string) { + return `${sourceId}::${bookId}::${normalizeHrefForMatch(href)}`; +} + +function loadScrolledPositions(): Record { + if (typeof window === 'undefined') return {}; + try { + const raw = localStorage.getItem(SCROLLED_POSITION_STORAGE_KEY); + return raw ? (JSON.parse(raw) as Record) : {}; + } catch { + return {}; + } +} + +function saveScrolledPosition(sourceId: string, bookId: string, position: ScrolledReadingPosition) { + if (typeof window === 'undefined') return; + const all = loadScrolledPositions(); + all[buildScrolledPositionKey(sourceId, bookId, position.href)] = position; + localStorage.setItem(SCROLLED_POSITION_STORAGE_KEY, JSON.stringify(all)); +} + +function getScrolledPosition(sourceId: string, bookId: string, href?: string): ScrolledReadingPosition | null { + const all = loadScrolledPositions(); + return all[buildScrolledPositionKey(sourceId, bookId, href)] || null; +} + +function getIframeScrollMetrics(viewer: HTMLDivElement | null) { + if (!viewer) return null; + + const iframe = viewer.querySelector('iframe'); + const doc = iframe?.contentDocument; + const win = iframe?.contentWindow; + + const elementCandidates = [ + viewer, + ...Array.from(viewer.querySelectorAll('div')), + ]; + + let bestElement: HTMLDivElement | null = null; + let bestOverflow = 0; + for (const candidate of elementCandidates) { + const el = candidate as HTMLDivElement; + const overflow = el.scrollHeight - el.clientHeight; + if (overflow > bestOverflow + 8) { + bestOverflow = overflow; + bestElement = el; + } + } + + const root = doc ? (doc.scrollingElement || doc.documentElement || doc.body) : null; + const rootOverflow = root ? Math.max((root.scrollHeight || 0) - (root.clientHeight || win?.innerHeight || 0), 0) : 0; + + if (root && rootOverflow >= bestOverflow) { + return { + iframe, + root, + scrollTop: Math.max(0, win?.scrollY || root.scrollTop || 0), + scrollHeight: Math.max(root.scrollHeight || 0, doc?.body?.scrollHeight || 0), + clientHeight: root.clientHeight || win?.innerHeight || 0, + setScrollTop: (value: number) => { + if (typeof root.scrollTo === 'function') { + root.scrollTo({ top: value, behavior: 'auto' }); + } else { + root.scrollTop = value; + } + }, + addScrollListener: (listener: () => void) => win?.addEventListener('scroll', listener, { passive: true }), + removeScrollListener: (listener: () => void) => win?.removeEventListener('scroll', listener), + interactionTarget: root, + }; + } + + if (bestElement) { + const scrollElement = bestElement; + return { + iframe, + root: scrollElement, + scrollTop: Math.max(0, scrollElement.scrollTop || 0), + scrollHeight: scrollElement.scrollHeight || 0, + clientHeight: scrollElement.clientHeight || 0, + setScrollTop: (value: number) => { + scrollElement.scrollTo({ top: value, behavior: 'auto' }); + }, + addScrollListener: (listener: () => void) => scrollElement.addEventListener('scroll', listener, { passive: true }), + removeScrollListener: (listener: () => void) => scrollElement.removeEventListener('scroll', listener), + interactionTarget: scrollElement, + }; + } + + return null; +} + +function computeScrolledTargetScrollTop(position: ScrolledReadingPosition, currentScrollHeight: number, currentClientHeight: number) { + const maxSaved = Math.max(0, position.scrollHeight - position.clientHeight); + const maxCurrent = Math.max(0, currentScrollHeight - currentClientHeight); + if (maxCurrent <= 0) return 0; + if (maxSaved <= 0) return Math.min(position.scrollTop, maxCurrent); + const ratio = Math.max(0, Math.min(1, position.scrollTop / maxSaved)); + return ratio * maxCurrent; +} + function flattenToc(items: TocItem[]): TocItem[] { return items.flatMap((item) => [item, ...flattenToc(item.subitems || [])]); } @@ -379,6 +493,25 @@ function chunkTtsText(text: string, maxChars: number): TtsChunk[] { return chunks; } + +function getRenditionOptions(mode: ReaderMode) { + return mode === 'scrolled' + ? { + width: '100%', + height: '100%', + spread: 'none', + manager: 'default', + flow: 'scrolled-doc', + } + : { + width: '100%', + height: '100%', + spread: 'none', + manager: 'default', + flow: 'paginated', + }; +} + function decodeBase64Audio(base64: string, mimeType: string) { const binary = typeof window === 'undefined' ? '' : window.atob(base64); const bytes = new Uint8Array(binary.length); @@ -425,7 +558,17 @@ export default function BookReadPage() { const [ttsDuration, setTtsDuration] = useState(0); const [ttsSeekValue, setTtsSeekValue] = useState(0); const [ttsSeeking, setTtsSeeking] = useState(false); + const [scrolledBottomReached, setScrolledBottomReached] = useState(false); const viewerRef = useRef(null); + const pendingScrolledRestoreRef = useRef(null); + const restoreTargetRef = useRef(undefined); + const scrollListenerCleanupRef = useRef<(() => void) | null>(null); + const scrolledAutoAdvanceLockRef = useRef(false); + const scrolledTouchStartYRef = useRef(null); + const scrolledBottomReachedRef = useRef(false); + const nextChapterHrefRef = useRef(''); + const bindScrolledIframeListenerRef = useRef<() => void>(() => undefined); + const applyPendingScrolledRestoreRef = useRef<() => void>(() => undefined); const tocScrollRef = useRef(null); const tocItemRefs = useRef>({}); const bookRef = useRef(null); @@ -434,10 +577,12 @@ export default function BookReadPage() { const pendingRecordDirtyRef = useRef(false); const saveInFlightRef = useRef(false); const lastLocationRef = useRef(null); + const settingsRef = useRef(DEFAULT_SETTINGS); const lastProgressRef = useRef(0); const lastChapterRef = useRef(''); const locationsReadyRef = useRef(false); const tocItemsRef = useRef([]); + const currentHrefRef = useRef(''); const audioRef = useRef(null); const ttsChunkAudioUrlRef = useRef>({}); const ttsChunkBlobCacheRef = useRef>({}); @@ -458,6 +603,7 @@ export default function BookReadPage() { }, []); useEffect(() => { + settingsRef.current = settings; if (typeof window !== 'undefined') { localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); } @@ -474,6 +620,10 @@ export default function BookReadPage() { ttsStatusRef.current = ttsStatus; }, [ttsStatus]); + useEffect(() => { + currentHrefRef.current = currentHref; + }, [currentHref]); + useEffect(() => { ttsSeekingRef.current = ttsSeeking; if (!ttsSeeking) { @@ -481,6 +631,10 @@ export default function BookReadPage() { } }, [ttsCurrentTime, ttsSeeking]); + useEffect(() => { + scrolledBottomReachedRef.current = scrolledBottomReached; + }, [scrolledBottomReached]); + useEffect(() => { const handleToggleSettings = () => { @@ -634,12 +788,51 @@ export default function BookReadPage() { } }, []); + + const persistScrolledPosition = useCallback((fallbackHref?: string) => { + if (!manifest || settingsRef.current.mode !== 'scrolled') return; + const metrics = getIframeScrollMetrics(viewerRef.current); + const href = fallbackHref || currentHrefRef.current || lastLocationRef.current?.start?.href || ''; + if (!metrics || !href) return; + saveScrolledPosition(manifest.book.sourceId, manifest.book.id, { + href, + scrollTop: metrics.scrollTop, + scrollHeight: metrics.scrollHeight, + clientHeight: metrics.clientHeight, + updatedAt: Date.now(), + }); + }, [manifest]); + + + const applyPendingScrolledRestore = useCallback(() => { + if (settingsRef.current.mode !== 'scrolled') return; + const pending = pendingScrolledRestoreRef.current; + if (!pending) return; + const metrics = getIframeScrollMetrics(viewerRef.current); + if (!metrics) return; + const currentHrefValue = lastLocationRef.current?.start?.href || currentHrefRef.current; + if (!currentHrefValue || !isSameTocTarget(currentHrefValue, pending.href)) return; + const targetScrollTop = computeScrolledTargetScrollTop(pending, metrics.scrollHeight, metrics.clientHeight); + metrics.setScrollTop(targetScrollTop); + pendingScrolledRestoreRef.current = null; + }, []); + + + + + + + useEffect(() => { + applyPendingScrolledRestoreRef.current = applyPendingScrolledRestore; + }, [applyPendingScrolledRestore]); + const persistCurrentProgress = useCallback(() => { if (lastLocationRef.current) { queueReadRecord(lastLocationRef.current, lastProgressRef.current, lastChapterRef.current); } + persistScrolledPosition(); void flushPendingReadRecord(); - }, [queueReadRecord, flushPendingReadRecord]); + }, [queueReadRecord, persistScrolledPosition, flushPendingReadRecord]); const applyReaderTheme = useCallback((nextSettings: ReaderSettings) => { const rendition = renditionRef.current; @@ -672,17 +865,17 @@ export default function BookReadPage() { const handleReaderTap = useCallback((zone: 'left' | 'center' | 'right') => { if (!ready) return; - if (zone === 'left') { + if (settings.mode === 'paginated' && zone === 'left') { renditionRef.current?.prev?.(); return; } - if (zone === 'right') { + if (settings.mode === 'paginated' && zone === 'right') { renditionRef.current?.next?.(); return; } setTocOpen(false); setSettingsOpen(false); - }, [ready]); + }, [ready, settings.mode]); const cleanupTtsAudioUrls = useCallback(() => { Object.values(ttsChunkBlobCacheRef.current).forEach((item) => URL.revokeObjectURL(item.url)); @@ -927,9 +1120,13 @@ export default function BookReadPage() { useEffect(() => { if (!manifest || manifest.format !== 'epub' || !viewerRef.current) return; let destroyed = false; + const currentSessionCfi = lastLocationRef.current?.start?.cfi || undefined; + const currentSessionHref = currentHrefRef.current || lastLocationRef.current?.start?.href || undefined; + setReady(false); setRestoredMessage(''); locationsReadyRef.current = false; + lastLocationRef.current = null; setProgressPercent(manifest.lastRecord?.progressPercent || 0); setCurrentChapter(manifest.lastRecord?.chapterTitle || manifest.lastRecord?.locator?.chapterTitle || ''); setFileLoadState('checking-cache'); @@ -937,6 +1134,13 @@ export default function BookReadPage() { setTotalBytes(null); setCacheHit(false); + const initialScrolledHref = currentSessionHref || manifest.lastRecord?.chapterHref || manifest.lastRecord?.locator?.href || undefined; + const cachedScrolledPosition = initialScrolledHref ? getScrolledPosition(manifest.book.sourceId, manifest.book.id, initialScrolledHref) : null; + pendingScrolledRestoreRef.current = settings.mode === 'scrolled' && !currentSessionHref ? cachedScrolledPosition : null; + restoreTargetRef.current = settings.mode === 'scrolled' + ? (initialScrolledHref || cachedScrolledPosition?.href || undefined) + : (currentSessionCfi || manifest.lastRecord?.locator?.value || undefined); + loadEpubScript() .then(async () => { if (!window.ePub || destroyed || !viewerRef.current) return; @@ -992,18 +1196,12 @@ export default function BookReadPage() { } }, 4000); - const rendition = book.renderTo(viewerRef.current, { - width: '100%', - height: '100%', - spread: 'none', - manager: 'default', - flow: 'paginated', - }); + const rendition = book.renderTo(viewerRef.current, getRenditionOptions(settings.mode)); bookRef.current = book; renditionRef.current = rendition; - applyReaderTheme(settings); + applyReaderTheme(settingsRef.current); - const restoreTarget = manifest.lastRecord?.locator?.value || undefined; + const restoreTarget = restoreTargetRef.current; let restoreMessageShown = false; rendition.on('relocated', (location: EpubLocation) => { @@ -1018,6 +1216,12 @@ export default function BookReadPage() { window.setTimeout(() => setRestoredMessage(''), 3000); } lastLocationRef.current = location; + scrolledAutoAdvanceLockRef.current = false; + setScrolledBottomReached(false); + window.requestAnimationFrame(() => { + bindScrolledIframeListenerRef.current(); + applyPendingScrolledRestoreRef.current(); + }); const hrefLabel = location?.start?.href ? findTocLabelByHref(tocItemsRef.current, location.start.href) : ''; const chapterTitle = hrefLabel || location?.start?.displayed?.chapter || location?.start?.href || manifest.book.title; const cfi = location?.start?.cfi || ''; @@ -1072,11 +1276,13 @@ export default function BookReadPage() { return () => { destroyed = true; + scrollListenerCleanupRef.current?.(); + scrollListenerCleanupRef.current = null; persistCurrentProgress(); renditionRef.current?.destroy?.(); bookRef.current?.destroy?.(); }; - }, [manifest, settings, applyReaderTheme, persistCurrentProgress, queueReadRecord, navigateToTarget]); + }, [manifest, settings.mode, applyReaderTheme, persistCurrentProgress, queueReadRecord, navigateToTarget]); useEffect(() => { const flushPendingReadRecordOnLeave = () => { @@ -1236,11 +1442,11 @@ export default function BookReadPage() { window.dispatchEvent(new CustomEvent('books-read-update-header', { detail: { title: manifest.book.title, - subtitle: currentTocLabel || currentChapter || manifest.book.author || '分页阅读', + subtitle: currentTocLabel || currentChapter || manifest.book.author || (settings.mode === 'scrolled' ? '滚动阅读' : '分页阅读'), backHref: `/books/detail?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`, }, })); - }, [manifest, currentChapter, currentTocLabel]); + }, [manifest, currentChapter, currentTocLabel, settings.mode]); useEffect(() => { if (!tocOpen || !activeTocHref) return; @@ -1248,6 +1454,142 @@ export default function BookReadPage() { if (!activeNode) return; activeNode.scrollIntoView({ block: 'center', behavior: 'smooth' }); }, [tocOpen, activeTocHref]); + const nextChapterHref = useMemo(() => { + const index = flatToc.findIndex((item) => isSameTocTarget(currentHref, item.href)); + if (index < 0) return flatToc[0]?.href || ''; + return flatToc[index + 1]?.href || ''; + }, [flatToc, currentHref]); + + useEffect(() => { + nextChapterHrefRef.current = nextChapterHref; + }, [nextChapterHref]); + + const goToNextChapter = useCallback(() => { + if (!nextChapterHref) return; + persistScrolledPosition(); + pendingScrolledRestoreRef.current = { + href: nextChapterHref, + scrollTop: 0, + scrollHeight: 1, + clientHeight: 1, + updatedAt: Date.now(), + }; + restoreTargetRef.current = nextChapterHref; + scrolledAutoAdvanceLockRef.current = true; + setScrolledBottomReached(false); + void navigateToTarget(nextChapterHref); + }, [nextChapterHref, navigateToTarget, persistScrolledPosition]); + const bindScrolledIframeListener = useCallback(() => { + scrollListenerCleanupRef.current?.(); + scrollListenerCleanupRef.current = null; + if (settingsRef.current.mode !== 'scrolled') return; + + let retryTimer = 0; + let rafId = 0; + + const attach = () => { + const metrics = getIframeScrollMetrics(viewerRef.current); + if (!metrics) { + retryTimer = window.setTimeout(attach, 120); + return; + } + + const isAtBottom = () => { + const latestMetrics = getIframeScrollMetrics(viewerRef.current); + if (!latestMetrics) return false; + const distanceToBottom = latestMetrics.scrollHeight - latestMetrics.clientHeight - latestMetrics.scrollTop; + return distanceToBottom <= 36; + }; + + const setBottomReached = (value: boolean) => { + if (scrolledBottomReachedRef.current === value) return; + scrolledBottomReachedRef.current = value; + setScrolledBottomReached(value); + }; + + const handleAdvanceIntent = () => { + if (!nextChapterHrefRef.current) return; + if (!isAtBottom()) return; + if (!scrolledBottomReachedRef.current) { + setBottomReached(true); + return; + } + if (scrolledAutoAdvanceLockRef.current) return; + goToNextChapter(); + }; + + const handleScroll = () => { + if (rafId) window.cancelAnimationFrame(rafId); + rafId = window.requestAnimationFrame(() => { + const latestMetrics = getIframeScrollMetrics(viewerRef.current); + if (!latestMetrics) return; + persistScrolledPosition(); + const distanceToBottom = latestMetrics.scrollHeight - latestMetrics.clientHeight - latestMetrics.scrollTop; + if (distanceToBottom <= 36) { + setBottomReached(true); + scrolledAutoAdvanceLockRef.current = false; + return; + } + setBottomReached(false); + scrolledAutoAdvanceLockRef.current = false; + }); + }; + + const handleWheel = (event: Event) => { + const wheel = event as WheelEvent; + if (wheel.deltaY > 24) handleAdvanceIntent(); + }; + + const handleTouchStart = (event: Event) => { + const touch = (event as TouchEvent).touches[0]; + scrolledTouchStartYRef.current = touch?.clientY ?? null; + }; + + const handleTouchMove = (event: Event) => { + const touch = (event as TouchEvent).touches[0]; + const startY = scrolledTouchStartYRef.current; + if (touch && startY !== null && startY - touch.clientY > 28) { + handleAdvanceIntent(); + scrolledTouchStartYRef.current = touch.clientY; + } + }; + + const handleTouchEnd = () => { + scrolledTouchStartYRef.current = null; + }; + + metrics.addScrollListener(handleScroll); + metrics.interactionTarget?.addEventListener('wheel', handleWheel, { passive: true }); + metrics.interactionTarget?.addEventListener('touchstart', handleTouchStart, { passive: true }); + metrics.interactionTarget?.addEventListener('touchmove', handleTouchMove, { passive: true }); + metrics.interactionTarget?.addEventListener('touchend', handleTouchEnd, { passive: true }); + viewerRef.current?.addEventListener('wheel', handleWheel, { passive: true }); + viewerRef.current?.addEventListener('touchstart', handleTouchStart, { passive: true }); + viewerRef.current?.addEventListener('touchmove', handleTouchMove, { passive: true }); + viewerRef.current?.addEventListener('touchend', handleTouchEnd, { passive: true }); + handleScroll(); + scrollListenerCleanupRef.current = () => { + if (retryTimer) window.clearTimeout(retryTimer); + if (rafId) window.cancelAnimationFrame(rafId); + metrics.removeScrollListener(handleScroll); + metrics.interactionTarget?.removeEventListener('wheel', handleWheel); + metrics.interactionTarget?.removeEventListener('touchstart', handleTouchStart); + metrics.interactionTarget?.removeEventListener('touchmove', handleTouchMove); + metrics.interactionTarget?.removeEventListener('touchend', handleTouchEnd); + viewerRef.current?.removeEventListener('wheel', handleWheel); + viewerRef.current?.removeEventListener('touchstart', handleTouchStart); + viewerRef.current?.removeEventListener('touchmove', handleTouchMove); + viewerRef.current?.removeEventListener('touchend', handleTouchEnd); + }; + }; + + attach(); + }, [goToNextChapter, persistScrolledPosition]); + + + useEffect(() => { + bindScrolledIframeListenerRef.current = bindScrolledIframeListener; + }, [bindScrolledIframeListener]); const renderTocItems = useCallback((items: TocItem[], depth = 0) => items.map((item) => { const active = tocItemIsActive(item, currentHref); @@ -1260,6 +1602,15 @@ export default function BookReadPage() { }} onClick={() => { if (!clickable) return; + persistScrolledPosition(); + pendingScrolledRestoreRef.current = { + href: item.href, + scrollTop: 0, + scrollHeight: 1, + clientHeight: 1, + updatedAt: Date.now(), + }; + restoreTargetRef.current = item.href; void navigateToTarget(item.href); setTocOpen(false); }} @@ -1275,7 +1626,11 @@ export default function BookReadPage() { {item.subitems?.length ? renderTocItems(item.subitems, depth + 1) : null} ); - }), [currentHref, navigateToTarget]); + }), [currentHref, navigateToTarget, persistScrolledPosition]); + + + + const showScrolledNextChapter = ready && settings.mode === 'scrolled' && !tocOpen && !settingsOpen && scrolledBottomReached && !!nextChapterHref; const progressLabel = totalBytes ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` : formatBytes(downloadedBytes); const ttsChunkPercent = ttsChunks.length > 0 ? ((ttsCurrentChunkIndex + 1) / ttsChunks.length) * 100 : 0; @@ -1295,15 +1650,15 @@ export default function BookReadPage() { } return ( -
+
{restoredMessage ? ( -
+
{restoredMessage}
) : null} {!ready ? ( -
+
@@ -1365,9 +1720,28 @@ export default function BookReadPage() { >
阅读设置
-
分页式 EPUB 阅读设置
+
可切换翻页或滚动阅读,默认翻页模式
+
+
阅读模式
+
+ {([ + { key: 'paginated', label: '翻页模式', desc: '左右点击翻页' }, + { key: 'scrolled', label: '滚动模式', desc: '上下连续滚动' }, + ] as { key: ReaderMode; label: string; desc: string }[]).map((mode) => ( + + ))} +
+
+
主题
@@ -1659,7 +2033,7 @@ export default function BookReadPage() { ) : null} - {ready && !tocOpen && !settingsOpen ? ( + {ready && !tocOpen && !settingsOpen && settings.mode === 'paginated' ? ( <> +
+ ) : null}
); }