From 93ea31b54625674ad3b58665c32bfefba1d70cbd Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 28 May 2026 21:46:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=A0dc31a788fc7ee8b23f07?= =?UTF-8?q?f1cfdcd5748c26103c24=20=E4=BA=A7=E7=94=9F=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/books/read/page.tsx | 204 ++++++++++++++++++++++++++++++++---- 1 file changed, 181 insertions(+), 23 deletions(-) diff --git a/src/app/books/read/page.tsx b/src/app/books/read/page.tsx index 07d6e53..41aee7f 100644 --- a/src/app/books/read/page.tsx +++ b/src/app/books/read/page.tsx @@ -103,6 +103,7 @@ interface EpubBookInstance { interface EpubRendition { display: (target?: string) => Promise; on: (event: 'relocated', callback: (location: EpubLocation) => void) => void; + currentLocation?: () => EpubLocation; prev?: () => void; next?: () => void; destroy?: () => void; @@ -469,6 +470,87 @@ function parseChapterScrollLocator(value?: string) { return Math.max(0, Math.min(1, ratio)); } +function getHrefFromScrollLocator(value?: string) { + if (!value || parseChapterScrollLocator(value) === null) return ''; + return value.split('#scroll=')[0] || ''; +} + +function getScrolledPositionFromRecord( + record?: BookReadRecord | null +): ScrolledReadingPosition | null { + const ratio = parseChapterScrollLocator(record?.locator?.value); + if (ratio === null) return null; + const href = + record?.chapterHref || + record?.locator?.href || + getHrefFromScrollLocator(record?.locator?.value); + if (!href) return null; + return { + href, + scrollTop: ratio, + scrollHeight: 2, + clientHeight: 1, + updatedAt: record?.saveTime || Date.now(), + }; +} + +function getWindowScrollMetrics() { + if (typeof window === 'undefined' || typeof document === 'undefined') + return null; + const root = document.scrollingElement || document.documentElement; + const scrollTop = Math.max( + 0, + window.scrollY || root?.scrollTop || document.body?.scrollTop || 0 + ); + const scrollHeight = Math.max( + root?.scrollHeight || 0, + document.documentElement?.scrollHeight || 0, + document.body?.scrollHeight || 0 + ); + const clientHeight = + root?.clientHeight || document.documentElement?.clientHeight || window.innerHeight || 0; + const maxScrollTop = Math.max(0, scrollHeight - clientHeight); + return { scrollTop, scrollHeight, clientHeight, maxScrollTop }; +} + +function getChapterScrollMetrics(node: HTMLDivElement | null) { + const nodeScrollHeight = node?.scrollHeight || 0; + const nodeClientHeight = node?.clientHeight || 0; + const nodeScrollTop = Math.max(0, node?.scrollTop || 0); + const nodeMaxScrollTop = Math.max(0, nodeScrollHeight - nodeClientHeight); + const windowMetrics = getWindowScrollMetrics(); + + if ( + windowMetrics && + windowMetrics.maxScrollTop > 0 && + (windowMetrics.scrollTop > 0 || nodeMaxScrollTop <= 0) + ) { + return windowMetrics; + } + + return { + scrollTop: nodeScrollTop, + scrollHeight: nodeScrollHeight, + clientHeight: nodeClientHeight, + maxScrollTop: nodeMaxScrollTop, + }; +} + +function setChapterScrollTop(node: HTMLDivElement | null, top: number) { + const metrics = getChapterScrollMetrics(node); + const targetTop = Math.max(0, Math.min(metrics.maxScrollTop, top)); + const windowMetrics = getWindowScrollMetrics(); + if ( + windowMetrics && + metrics.scrollHeight === windowMetrics.scrollHeight && + metrics.clientHeight === windowMetrics.clientHeight + ) { + window.scrollTo({ top: targetTop, behavior: 'auto' }); + return; + } + node?.scrollTo({ top: targetTop, behavior: 'auto' }); +} + function flattenToc(items: TocItem[]): TocItem[] { return items.flatMap((item) => [item, ...flattenToc(item.subitems || [])]); } @@ -594,6 +676,9 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { const restoredChapterPositionRef = useRef(false); const chapterSaveTimerRef = useRef(null); const pendingChapterRestoreRatioRef = useRef(null); + const lastChapterScrollMetricsRef = useRef | null>(null); const currentIndexRef = useRef(0); const lastChapterSavedAtRef = useRef(0); const lastChapterSavedLocatorValueRef = useRef( @@ -735,12 +820,15 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { const buildChapterReadRecord = useCallback( (item: BookChapter, index: number): BookReadRecord => { - const node = scrollRef.current; - const scrollTop = node?.scrollTop || 0; - const scrollHeight = node?.scrollHeight || 0; - const clientHeight = node?.clientHeight || 0; + const currentMetrics = getChapterScrollMetrics(scrollRef.current); + const savedMetrics = lastChapterScrollMetricsRef.current; + const { scrollTop, scrollHeight, clientHeight, maxScrollTop } = + currentMetrics.scrollTop === 0 && + savedMetrics && + savedMetrics.scrollTop > 0 + ? savedMetrics + : currentMetrics; const chapterCount = Math.max(1, chapters.length); - const maxScrollTop = Math.max(0, scrollHeight - clientHeight); const chapterRatio = maxScrollTop > 0 ? Math.max(0, Math.min(1, scrollTop / maxScrollTop)) @@ -861,7 +949,8 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { stopTts(true); setLoading(true); setError(''); - scrollRef.current?.scrollTo({ top: 0, behavior: 'auto' }); + lastChapterScrollMetricsRef.current = null; + setChapterScrollTop(scrollRef.current, 0); pendingChapterRestoreRatioRef.current = null; const params = new URLSearchParams({ sourceId: manifest.book.sourceId, @@ -930,18 +1019,21 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { const node = scrollRef.current; if (!node) return; if (settings.mode === 'scrolled') return; - const delta = Math.max(240, node.clientHeight * 0.88) * direction; - const maxTop = Math.max(0, node.scrollHeight - node.clientHeight); - const nextTop = Math.max(0, Math.min(maxTop, node.scrollTop + delta)); - if (direction > 0 && node.scrollTop >= maxTop - 8) { + const metrics = getChapterScrollMetrics(node); + const delta = Math.max(240, metrics.clientHeight * 0.88) * direction; + const nextTop = Math.max( + 0, + Math.min(metrics.maxScrollTop, metrics.scrollTop + delta) + ); + if (direction > 0 && metrics.scrollTop >= metrics.maxScrollTop - 8) { if (currentIndex < chapters.length - 1) goNextChapter(); return; } - if (direction < 0 && node.scrollTop <= 8) { + if (direction < 0 && metrics.scrollTop <= 8) { if (currentIndex > 0) goPrevChapter(); return; } - node.scrollTo({ top: nextTop, behavior: 'smooth' }); + setChapterScrollTop(node, nextTop); }, [chapters.length, currentIndex, goNextChapter, goPrevChapter, settings.mode] ); @@ -1062,12 +1154,12 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { if (attempt < 20) window.setTimeout(() => restore(attempt + 1), 100); return; } - const maxScrollTop = Math.max(0, node.scrollHeight - node.clientHeight); + const { maxScrollTop } = getChapterScrollMetrics(node); if (maxScrollTop <= 0 && attempt < 20) { window.setTimeout(() => restore(attempt + 1), 100); return; } - node.scrollTo({ top: maxScrollTop * savedRatio, behavior: 'auto' }); + setChapterScrollTop(node, maxScrollTop * savedRatio); restoredChapterPositionRef.current = true; pendingChapterRestoreRatioRef.current = null; window.setTimeout(() => persistChapterProgress(), 0); @@ -1082,11 +1174,21 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) { useEffect(() => { const node = scrollRef.current; if (!node) return; - node.addEventListener('scroll', scheduleChapterProgressSave, { + const handleScroll = () => { + lastChapterScrollMetricsRef.current = getChapterScrollMetrics( + scrollRef.current + ); + scheduleChapterProgressSave(); + }; + node.addEventListener('scroll', handleScroll, { + passive: true, + }); + window.addEventListener('scroll', handleScroll, { passive: true, }); return () => { - node.removeEventListener('scroll', scheduleChapterProgressSave); + node.removeEventListener('scroll', handleScroll); + window.removeEventListener('scroll', handleScroll); if (chapterSaveTimerRef.current) { window.clearTimeout(chapterSaveTimerRef.current); chapterSaveTimerRef.current = null; @@ -2217,8 +2319,37 @@ export default function BookReadPage() { clientHeight: metrics.clientHeight, updatedAt: Date.now(), }); + const chapterTitle = lastChapterRef.current || currentChapter || manifest.book.title; + pendingRecordRef.current = { + sourceId: manifest.book.sourceId, + sourceName: manifest.book.sourceName, + bookId: manifest.book.id, + title: manifest.book.title, + author: manifest.book.author, + cover: manifest.book.cover, + detailHref: manifest.book.detailHref, + acquisitionHref: manifest.acquisitionHref, + format: manifest.format, + locator: { + type: 'href', + value: encodeChapterScrollLocator( + href, + metrics.scrollTop, + metrics.scrollHeight, + metrics.clientHeight + ), + href, + chapterTitle, + }, + chapterTitle, + chapterHref: href, + progressPercent: + lastProgressRef.current ?? manifest.lastRecord?.progressPercent ?? 0, + saveTime: Date.now(), + }; + pendingRecordDirtyRef.current = true; }, - [manifest] + [currentChapter, manifest] ); const applyPendingScrolledRestore = useCallback(() => { @@ -2285,6 +2416,27 @@ export default function BookReadPage() { await renditionRef.current.display(target); }, []); + const captureCurrentEpubLocation = useCallback(() => { + const location = renditionRef.current?.currentLocation?.(); + if (location?.start?.cfi || location?.end?.cfi) { + lastLocationRef.current = location; + currentHrefRef.current = + location.start?.href || currentHrefRef.current || ''; + return location; + } + return lastLocationRef.current; + }, []); + + const switchReaderMode = useCallback( + (mode: ReaderMode) => { + if (mode === settingsRef.current.mode) return; + captureCurrentEpubLocation(); + persistCurrentProgress(); + setSettings((prev) => ({ ...prev, mode })); + }, + [captureCurrentEpubLocation, persistCurrentProgress] + ); + const handleReaderTap = useCallback( (zone: 'left' | 'center' | 'right') => { if (!ready) return; @@ -2619,7 +2771,11 @@ export default function BookReadPage() { currentSessionHref || manifest.lastRecord?.chapterHref || manifest.lastRecord?.locator?.href || + getHrefFromScrollLocator(manifest.lastRecord?.locator?.value) || undefined; + const historyScrolledPosition = getScrolledPositionFromRecord( + manifest.lastRecord + ); const cachedScrolledPosition = initialScrolledHref ? getScrolledPosition( manifest.book.sourceId, @@ -2628,12 +2784,16 @@ export default function BookReadPage() { ) : null; pendingScrolledRestoreRef.current = - settings.mode === 'scrolled' && !currentSessionHref - ? cachedScrolledPosition + settings.mode === 'scrolled' && !currentSessionCfi + ? historyScrolledPosition || cachedScrolledPosition : null; restoreTargetRef.current = settings.mode === 'scrolled' - ? initialScrolledHref || cachedScrolledPosition?.href || undefined + ? currentSessionCfi || + initialScrolledHref || + historyScrolledPosition?.href || + cachedScrolledPosition?.href || + undefined : currentSessionCfi || manifest.lastRecord?.locator?.value || undefined; loadEpubScript() @@ -3422,9 +3582,7 @@ export default function BookReadPage() { ).map((mode) => (