Fix preserving scroll position on infinite scroll previous chapter load (#913)

When scrolling to the previous chapter, which has not been preloaded, the scroll position got lost due to the resulting layout shift

* Update scroll preserve by tracking cursor

* Clean up interface

* Observe just the wrappers instead of individual images

* Switch to `IntersectionObserver` for tracking active element

* Clean up adding of observations

Co-authored-by: schroda <50052685+schroda@users.noreply.github.com>

---------

Co-authored-by: schroda <50052685+schroda@users.noreply.github.com>
This commit is contained in:
Constantin Piber
2025-04-05 12:45:52 +02:00
committed by GitHub
parent b3860e4351
commit 4423dee14e
2 changed files with 72 additions and 57 deletions

View File

@@ -282,14 +282,9 @@ const BaseReaderViewer = forwardRef(
useReaderAutoScroll(isOverlayVisible, automaticScrolling); useReaderAutoScroll(isOverlayVisible, automaticScrolling);
useReaderPreserveScrollPosition( useReaderPreserveScrollPosition(
scrollElementRef, scrollElementRef,
currentChapter?.id,
currentChapterIndex,
currentPageIndex, currentPageIndex,
chaptersToRender,
visibleChapters,
readingMode, readingMode,
isContinuousReadingModeActive, isContinuousReadingModeActive,
readingDirection,
readerNavBarWidth, readerNavBarWidth,
setPageToScrollToIndex, setPageToScrollToIndex,
pageScaleMode, pageScaleMode,

View File

@@ -7,35 +7,26 @@
*/ */
import { RefObject, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import { RefObject, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { ChapterIdInfo } from '@/modules/chapter/services/Chapters.ts'; import { ReaderPageScaleMode, ReadingMode } from '@/modules/reader/types/Reader.types.ts';
import {
ReaderPageScaleMode,
ReaderStateChapters,
ReadingDirection,
ReadingMode,
} from '@/modules/reader/types/Reader.types.ts';
import { getOptionForDirection } from '@/modules/theme/services/ThemeCreator.ts';
import { READING_DIRECTION_TO_THEME_DIRECTION } from '@/modules/reader/constants/ReaderSettings.constants.tsx';
import { getPreviousNextChapterVisibility } from '@/modules/reader/utils/Reader.utils.ts';
import { TChapterReader } from '@/modules/chapter/Chapter.types.ts';
import { ReaderStatePages } from '@/modules/reader/types/ReaderProgressBar.types'; import { ReaderStatePages } from '@/modules/reader/types/ReaderProgressBar.types';
import { isReaderWidthEditable } from '@/modules/reader/utils/ReaderSettings.utils.tsx'; import { isReaderWidthEditable } from '@/modules/reader/utils/ReaderSettings.utils.tsx';
export const useReaderPreserveScrollPosition = ( export const useReaderPreserveScrollPosition = (
scrollElementRef: RefObject<HTMLElement | null>, scrollElementRef: RefObject<HTMLElement | null>,
currentChapterId: ChapterIdInfo['id'] | undefined,
chapterIndex: number,
pageIndex: number, pageIndex: number,
chaptersToRender: TChapterReader[],
visibleChapters: ReaderStateChapters['visibleChapters'],
readingMode: ReadingMode, readingMode: ReadingMode,
isContinuousReadingModeActive: boolean, isContinuousReadingModeActive: boolean,
readingDirection: ReadingDirection,
readerNavBarWidth: number, readerNavBarWidth: number,
setPageToScrollToIndex: ReaderStatePages['setPageToScrollToIndex'], setPageToScrollToIndex: ReaderStatePages['setPageToScrollToIndex'],
pageScaleMode: ReaderPageScaleMode, pageScaleMode: ReaderPageScaleMode,
) => { ) => {
const scrollPosition = useRef({ left: 0, top: 0, scrollWidth: 0, scrollHeight: 0 }); const scrollPosition = useRef({
left: 0,
top: 0,
active: undefined as HTMLElement | undefined,
activeLeft: 0,
activeTop: 0,
});
const readerNavBarWidthRef = useRef<number>(readerNavBarWidth); const readerNavBarWidthRef = useRef<number>(readerNavBarWidth);
useEffect(() => { useEffect(() => {
@@ -46,10 +37,13 @@ export const useReaderPreserveScrollPosition = (
} }
const onScroll = () => { const onScroll = () => {
const { active } = scrollPosition.current;
scrollPosition.current = { scrollPosition.current = {
...scrollPosition.current, ...scrollPosition.current,
left: element.scrollLeft, left: element.scrollLeft,
top: element.scrollTop, top: element.scrollTop,
activeLeft: active?.offsetLeft ?? 0,
activeTop: active?.offsetTop ?? 0,
}; };
}; };
@@ -59,52 +53,78 @@ export const useReaderPreserveScrollPosition = (
}, []); }, []);
// on rendering previous chapter (infinite scroll in continuous reading modes) // on rendering previous chapter (infinite scroll in continuous reading modes)
useLayoutEffect(() => { const onDoPreserveScroll = useCallback(() => {
const scrollElement = scrollElementRef.current; const scrollElement = scrollElementRef.current;
const { left, top, scrollWidth, scrollHeight } = scrollPosition.current; const { left, top, active, activeLeft, activeTop } = scrollPosition.current;
if (!scrollElement || !isContinuousReadingModeActive) { if (!scrollElement || !isContinuousReadingModeActive) {
return; return;
} }
scrollPosition.current = { if (!active) return;
...scrollPosition.current,
scrollWidth: scrollElement.scrollWidth,
scrollHeight: scrollElement.scrollHeight,
};
const themeDirectionForReadingDirection = READING_DIRECTION_TO_THEME_DIRECTION[readingDirection]; const newLeft = left - activeLeft + active.offsetLeft;
const newTop = top - activeTop + active.offsetTop;
scrollElement.scrollTo(newLeft, newTop);
}, [scrollElementRef, isContinuousReadingModeActive]);
const previousNextChapterVisibility = getPreviousNextChapterVisibility( useLayoutEffect(() => {
chapterIndex, const element = scrollElementRef.current;
chaptersToRender,
visibleChapters,
);
const wasScrolledBackwardHorizontal = Math.abs(left) < window.innerWidth * 1.5; if (!element) {
const wasScrolledBackwardVertical = top < window.innerHeight * 1.5; return () => {};
const widthOfPrependedContent = scrollElement.scrollWidth - scrollWidth;
const heightOfPrependedContent = scrollElement.scrollHeight - scrollHeight;
const newLeft = wasScrolledBackwardHorizontal
? getOptionForDirection(
widthOfPrependedContent,
-widthOfPrependedContent,
themeDirectionForReadingDirection,
) + left
: left;
const newTop = wasScrolledBackwardVertical ? heightOfPrependedContent + top : top;
// only relevant when prepending content to the dom due to the resulting layout shift
const isFirstRenderOfPreviousChapter =
!previousNextChapterVisibility.previous && (wasScrolledBackwardHorizontal || wasScrolledBackwardVertical);
if (!isFirstRenderOfPreviousChapter) {
return;
} }
scrollElement.scrollTo(newLeft, newTop); const updateObservation = (
}, [currentChapterId]); nodes: NodeList,
resizeAction: (n: Element) => void,
intersectionAction: (n: Element) => void,
) =>
Array.from(nodes)
.filter((n) => n instanceof HTMLElement)
.flatMap((n) => {
resizeAction(n);
return Array.from(n.querySelectorAll('img'));
})
.forEach(intersectionAction);
const resizeObserver = new ResizeObserver(onDoPreserveScroll);
const intersectionObserver = new IntersectionObserver((entries) => {
// find the first visible image inside the viewport
const first = entries.filter((e) => e.isIntersecting).shift();
if (!first || !(first.target instanceof HTMLElement)) return;
scrollPosition.current = {
...scrollPosition.current,
active: first.target,
activeLeft: first.target.offsetLeft,
activeTop: first.target.offsetTop,
};
});
const mutationObserver = new MutationObserver((entries) => {
for (const entry of entries) {
updateObservation(
entry.addedNodes,
(n) => resizeObserver.observe(n),
(n) => intersectionObserver.observe(n),
);
updateObservation(
entry.removedNodes,
(n) => resizeObserver.unobserve(n),
(n) => intersectionObserver.unobserve(n),
);
}
});
mutationObserver.observe(element, {
childList: true,
subtree: true,
});
return () => {
mutationObserver.disconnect();
resizeObserver.disconnect();
intersectionObserver.disconnect();
};
}, [scrollElementRef, onDoPreserveScroll]);
const onAvailableReaderWidthChange = useCallback(() => { const onAvailableReaderWidthChange = useCallback(() => {
if (!isContinuousReadingModeActive) { if (!isContinuousReadingModeActive) {