Files
suwayomi-material-you-webui/src/features/reader/viewer/hooks/useReaderPreserveScrollPosition.ts

362 lines
13 KiB
TypeScript
Raw Normal View History

/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
2025-05-02 17:56:56 +02:00
import { RefObject, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { useWindowEvent } from '@mantine/hooks';
import {
ReaderPageScaleMode,
ReaderStateChapters,
ReaderStatePages,
ReadingDirection,
ReadingMode,
2025-08-16 02:02:13 +02:00
} from '@/features/reader/Reader.types.ts';
import {
isContinuousReadingMode,
isContinuousVerticalReadingMode,
isHeightPageScaleMode,
isReaderWidthEditable,
2025-08-16 02:02:13 +02:00
} from '@/features/reader/settings/ReaderSettings.utils.tsx';
import { getPreviousNextChapterVisibility } from '@/features/reader/Reader.utils.ts';
2025-08-15 22:02:58 +02:00
import { ChapterIdInfo, TChapterReader } from '@/features/chapter/Chapter.types.ts';
const shouldPreserveOnResizeChange = (
readingMode: ReadingMode,
pageScaleMode: ReaderPageScaleMode,
previousWidth: number,
previousHeight: number,
): boolean => {
const isEditableReaderWidth = isReaderWidthEditable(pageScaleMode);
const isHeightPageScaleModeActive = isHeightPageScaleMode(pageScaleMode);
const didWidthChange = previousWidth !== window.innerWidth;
const didHeightChange = previousHeight !== window.innerHeight;
const handleWidthChange = isEditableReaderWidth && didWidthChange;
const handleHeightChange = isHeightPageScaleModeActive && didHeightChange;
if (!isContinuousVerticalReadingMode(readingMode) && didWidthChange) {
return true;
}
return handleWidthChange || handleHeightChange;
};
const usePreserveOnValueChange = (
value: unknown,
currentPageIndex: number,
setPageToScrollToIndex: ReaderStatePages['setPageToScrollToIndex'],
) => {
useLayoutEffect(() => {
setPageToScrollToIndex(currentPageIndex);
}, [value]);
};
const usePreserveOnWindowResize = (
readingMode: ReadingMode,
pageScaleMode: ReaderPageScaleMode,
setPageToScrollToIndex: React.Dispatch<React.SetStateAction<number | null>>,
pageIndex: number,
) => {
const previousDimensionsRef = useRef({ width: window.innerWidth, height: window.innerHeight });
2025-05-02 17:56:56 +02:00
const handleResize = useCallback(() => {
const { width, height } = previousDimensionsRef.current;
previousDimensionsRef.current = { width: window.innerWidth, height: window.innerHeight };
2025-05-02 17:56:56 +02:00
if (!shouldPreserveOnResizeChange(readingMode, pageScaleMode, width, height)) {
return;
}
2025-05-02 17:56:56 +02:00
setPageToScrollToIndex(pageIndex);
}, [readingMode, pageScaleMode, pageIndex]);
2025-05-02 17:56:56 +02:00
useWindowEvent('resize', handleResize);
};
interface ScrollPreservationInfo {
left: number;
top: number;
visibleElement: HTMLElement | undefined;
visibleElementLeft: number;
visibleElementTop: number;
}
const useScrollPreservationData = (
scrollElementRef: RefObject<HTMLElement | null>,
): RefObject<ScrollPreservationInfo> => {
const dataRef = useRef<ScrollPreservationInfo>({
left: 0,
top: 0,
visibleElement: undefined,
visibleElementLeft: 0,
visibleElementTop: 0,
});
useEffect(() => {
const scrollElement = scrollElementRef.current;
if (!scrollElement) {
return () => {};
}
const onScroll = () => {
const { visibleElement } = dataRef.current;
dataRef.current = {
...dataRef.current,
left: scrollElement.scrollLeft,
top: scrollElement.scrollTop,
visibleElementLeft: visibleElement?.offsetLeft ?? 0,
visibleElementTop: visibleElement?.offsetTop ?? 0,
};
};
scrollElement.addEventListener('scroll', onScroll);
return () => scrollElement.removeEventListener('scroll', onScroll);
}, []);
useEffect(() => {
const scrollElement = scrollElementRef.current;
if (!scrollElement) {
return () => {};
}
const updateObservation = (nodes: NodeList, intersectionAction: (element: Element) => void) =>
Array.from(nodes)
.filter((node) => node instanceof HTMLElement)
.flatMap((element) =>
element instanceof HTMLImageElement ? [element] : Array.from(element.querySelectorAll('img')),
)
.forEach(intersectionAction);
const intersectionObserver = new IntersectionObserver((entries) => {
const firstVisibleElement = entries.find((entry) => entry.isIntersecting);
if (!(firstVisibleElement?.target instanceof HTMLElement)) {
return;
}
dataRef.current = {
...dataRef.current,
visibleElement: firstVisibleElement.target,
visibleElementLeft: firstVisibleElement.target.offsetLeft,
visibleElementTop: firstVisibleElement.target.offsetTop,
};
});
const mutationObserver = new MutationObserver((entries) => {
for (const entry of entries) {
updateObservation(entry.addedNodes, (element) => intersectionObserver.observe(element));
updateObservation(entry.removedNodes, (element) => intersectionObserver.unobserve(element));
}
});
mutationObserver.observe(scrollElement, {
childList: true,
subtree: true,
});
return () => {
mutationObserver.disconnect();
intersectionObserver.disconnect();
};
}, []);
return dataRef;
};
const usePreserveOnLeadingPageRender = (scrollElementRef: RefObject<HTMLElement | null>, readingMode: ReadingMode) => {
const preservationData = useScrollPreservationData(scrollElementRef);
const isContinuousReadingModeActive = isContinuousReadingMode(readingMode);
const isContinuousVerticalReadingModeActive = isContinuousVerticalReadingMode(readingMode);
useEffect(() => {
const scrollElement = scrollElementRef.current;
if (!scrollElement || !isContinuousReadingModeActive) {
return () => {};
}
const preserveScrollPosition: ResizeObserverCallback = (entries) => {
const { left, top, visibleElement, visibleElementLeft, visibleElementTop } = preservationData.current;
if (!visibleElement) {
return;
}
const entriesBeforeScrollPosition = entries.filter((entry) => {
if (!(entry.target instanceof HTMLElement)) {
return false;
}
const isPreloadPage = !entry.target.clientWidth && !entry.target.clientHeight;
if (isPreloadPage) {
return false;
}
if (isContinuousVerticalReadingModeActive) {
return entry.target.offsetTop < top;
}
return Math.abs(entry.target.offsetLeft) < Math.abs(left);
});
const includesElementsBeforeScrollPosition = !!entriesBeforeScrollPosition.length;
if (!includesElementsBeforeScrollPosition) {
return;
}
const newLeft = left - visibleElementLeft + visibleElement.offsetLeft;
const newTop = top - visibleElementTop + visibleElement.offsetTop;
scrollElement.scrollTo(newLeft, newTop);
};
const updateObservation = (nodes: NodeList, resizeAction: (element: Element) => void) =>
Array.from(nodes)
.filter((node) => node instanceof HTMLElement)
.flatMap((element) =>
element instanceof HTMLImageElement ? [element] : Array.from(element.querySelectorAll('img')),
)
.forEach(resizeAction);
const resizeObserver = new ResizeObserver(preserveScrollPosition);
const mutationObserver = new MutationObserver((entries) => {
for (const entry of entries) {
updateObservation(entry.addedNodes, (element) => resizeObserver.observe(element));
updateObservation(entry.removedNodes, (element) => resizeObserver.unobserve(element));
}
});
mutationObserver.observe(scrollElement, {
childList: true,
subtree: true,
});
return () => {
mutationObserver.disconnect();
resizeObserver.disconnect();
};
}, [isContinuousReadingModeActive, isContinuousVerticalReadingModeActive]);
};
Reader preserve current page on window resize (#905) * Reader: Reset bounds on window size change 19f2d993 introduced a `minWidth` to make sure sizes are consistent over chapter changes (or when images load later), but when the window itself changes, this makes the reader incorrectly sized * Reader: Update size on navbar width change This happens mostly when the bar is pinned; same effect as previous commit * Reader: Attempt to set the page on resize It's not perfect, but not changing position at all seems impossible due to how resizes are handled by the browser (since we adjust our content to the window size) * Simplify and abstract reader size reset Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * fix: Reader page full width was not properly calculated for static navbar Only relevant for Fit Content, this did not properly consider the navbar when the user clamps the content to a custom width * Refactor: Put reader min sizes to reader itself instead of chapter This allows some logic simplification. The `ResizeObserver` can needs to be on an element without restrictions; previously, the observer would not see the chapter elements get smaller, since the `min-width` prevented it from shrinking. By applying to the outer element, we still preserve the centering (since `margin: auto` is also applied here), but allow the chapter itself to be smaller. * Reader: Only scroll in continuous reading modes * Reader: clean up size tracking Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * Fixup previous commit stupidness * Reader: move `minWidth`/`minHeight` back to chapter wrapper * Apply suggestions from code review Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * Also revert `fullWidth` calculation * Simplify `chapterViewerSize` Co-authored-by: schroda <50052685+schroda@users.noreply.github.com> * Move size logic entirely to hook --------- Co-authored-by: schroda <50052685+schroda@users.noreply.github.com>
2025-03-24 23:45:06 +01:00
const usePreserveOnInfiniteScrollPreviousChapterInitialRender = (
scrollElementRef: RefObject<HTMLElement | null>,
currentChapterId: ChapterIdInfo['id'] | undefined,
currentChapterIndex: number,
currentPageIndex: number,
chaptersToRender: TChapterReader[],
visibleChapters: ReaderStateChapters['visibleChapters'],
isContinuousReadingModeActive: boolean,
) => {
const preservationDataRef = useScrollPreservationData(scrollElementRef);
const preserveScrollPosition = (): boolean => {
const scrollElement = scrollElementRef.current;
const { left, top, visibleElementLeft, visibleElementTop, visibleElement } = preservationDataRef.current;
if (!scrollElement || !isContinuousReadingModeActive || !visibleElement) {
return false;
}
const previousNextChapterVisibility = getPreviousNextChapterVisibility(
currentChapterIndex,
chaptersToRender,
visibleChapters,
);
const isRenderOfPreviousChapter = currentPageIndex === 0;
// only relevant when prepending content to the dom due to the resulting layout shift
const isFirstRenderOfPreviousChapter = !previousNextChapterVisibility.previous && isRenderOfPreviousChapter;
if (!isFirstRenderOfPreviousChapter) {
return false;
}
const newLeft = left - visibleElementLeft + visibleElement.offsetLeft;
const newTop = top - visibleElementTop + visibleElement.offsetTop;
scrollElement.scrollTo(newLeft, newTop);
return true;
};
useEffect(() => {
const scrollElement = scrollElementRef.current;
if (!scrollElement || !isContinuousReadingModeActive) {
return () => {};
}
let preservedScrollPosition = false;
const updateObservation = (nodes: NodeList, resizeAction: (element: Element) => void) => {
Array.from(nodes)
.filter((node) => node instanceof HTMLElement)
.flatMap((element) =>
element instanceof HTMLImageElement ? [element] : Array.from(element.querySelectorAll('img')),
)
.forEach(resizeAction);
};
const resizeObserver = new ResizeObserver(() => {
if (preservedScrollPosition) {
return;
}
preservedScrollPosition = preserveScrollPosition();
if (preservedScrollPosition) {
resizeObserver.disconnect();
}
});
const mutationObserver = new MutationObserver((entries) => {
for (const entry of entries) {
updateObservation(entry.addedNodes, (element) => resizeObserver.observe(element));
updateObservation(entry.removedNodes, (element) => resizeObserver.unobserve(element));
}
});
mutationObserver.observe(scrollElement, {
childList: true,
subtree: true,
});
return () => {
mutationObserver.disconnect();
resizeObserver.disconnect();
};
}, [currentChapterId]);
useLayoutEffect(() => {
preserveScrollPosition();
}, [currentChapterId]);
};
export const useReaderPreserveScrollPosition = (
scrollElementRef: RefObject<HTMLElement | null>,
currentChapterId: ChapterIdInfo['id'] | undefined,
currentChapterIndex: number,
currentPageIndex: number,
chaptersToRender: TChapterReader[],
visibleChapters: ReaderStateChapters['visibleChapters'],
readingMode: ReadingMode,
readingDirection: ReadingDirection,
setPageToScrollToIndex: ReaderStatePages['setPageToScrollToIndex'],
pageScaleMode: ReaderPageScaleMode,
) => {
usePreserveOnInfiniteScrollPreviousChapterInitialRender(
scrollElementRef,
currentChapterId,
currentChapterIndex,
currentPageIndex,
chaptersToRender,
visibleChapters,
isContinuousReadingMode(readingMode),
);
usePreserveOnLeadingPageRender(scrollElementRef, readingMode);
usePreserveOnWindowResize(readingMode, pageScaleMode, setPageToScrollToIndex, currentPageIndex);
usePreserveOnValueChange(readingDirection, currentPageIndex, setPageToScrollToIndex);
usePreserveOnValueChange(readingMode, currentPageIndex, setPageToScrollToIndex);
};