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:
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user