Add infinite scroll to reader

This commit is contained in:
schroda
2025-01-28 02:16:00 +01:00
parent 4789a74785
commit 4a9d2903b8
20 changed files with 1068 additions and 238 deletions

View File

@@ -0,0 +1,120 @@
/*
* 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/.
*/
import { useLayoutEffect } from 'react';
import { ReaderControls } from '@/modules/reader/services/ReaderControls.ts';
import { ReadingDirection, ReadingMode } from '@/modules/reader/types/Reader.types.ts';
import {
isContinuousReadingMode,
isContinuousVerticalReadingMode,
} from '@/modules/reader/utils/ReaderSettings.utils.tsx';
import { READING_DIRECTION_TO_THEME_DIRECTION } from '@/modules/reader/constants/ReaderSettings.constants.tsx';
import { getOptionForDirection } from '@/modules/theme/services/ThemeCreator.ts';
const OPEN_CHAPTER_INTERSECTION_RATIO = 0.1;
const getScrollingDirection = (
readingMode: ReadingMode,
readingDirection: ReadingDirection,
{ bottom, left, right }: DOMRect,
): 'backward' | 'forward' => {
if (isContinuousVerticalReadingMode(readingMode)) {
const isScrollingBackward = bottom >= window.innerHeight;
if (isScrollingBackward) {
return 'backward';
}
return 'forward';
}
const themeDirectionOfReadingDirection = READING_DIRECTION_TO_THEME_DIRECTION[readingDirection];
const endOfElement = getOptionForDirection(right, left, themeDirectionOfReadingDirection);
const isScrollingBackward =
endOfElement >= getOptionForDirection(window.innerWidth, 0, themeDirectionOfReadingDirection);
if (isScrollingBackward) {
return 'backward';
}
return 'forward';
};
export const useReaderInfiniteScrollUpdateChapter = (
pageType: 'first' | 'last',
chapterId: number,
chapterToOpenId: number | undefined,
isCurrentChapter: boolean,
isChapterToOpenVisible: boolean,
readingMode: ReadingMode,
readingDirection: ReadingDirection,
openChapter: ReturnType<typeof ReaderControls.useOpenChapter>,
image: HTMLElement | null,
) => {
useLayoutEffect(() => {
if (!image || !isContinuousReadingMode(readingMode) || chapterToOpenId === undefined) {
return () => {};
}
// gets immediately observed once on initial render
let isInitialObserve = true;
let wasNextChapterOpened = false;
const intersectionObserver = new IntersectionObserver(
(entries) => {
if (isInitialObserve) {
isInitialObserve = false;
return;
}
if (wasNextChapterOpened) {
return;
}
const entry = entries[entries.length - 1];
const wasPageScrolledOutOfView = entry.intersectionRatio < OPEN_CHAPTER_INTERSECTION_RATIO;
const isScrollingBackward =
getScrollingDirection(readingMode, readingDirection, entry.target.getBoundingClientRect()) ===
'backward';
const isScrollingForward = !isScrollingBackward;
// the first page only opens the previous chapter in case it hasn't been loaded yet, otherwise, the last
// page handles setting the correct chapter
const initialOpenPreviousChapter =
pageType === 'first' && isScrollingBackward && !isChapterToOpenVisible;
const openPreviousChapter = pageType === 'last' && !isCurrentChapter && isScrollingForward;
const openNextChapter = pageType === 'last' && isScrollingForward && wasPageScrolledOutOfView;
const openChapterToOpen = initialOpenPreviousChapter || openNextChapter;
if (openChapterToOpen) {
openChapter(chapterToOpenId, !isChapterToOpenVisible, false);
wasNextChapterOpened = true;
return;
}
if (openPreviousChapter) {
openChapter(chapterId, false, false);
wasNextChapterOpened = true;
}
},
{ threshold: [OPEN_CHAPTER_INTERSECTION_RATIO] },
);
intersectionObserver.observe(image);
return () => intersectionObserver.unobserve(image);
}, [
pageType,
chapterId,
chapterToOpenId,
isCurrentChapter,
isChapterToOpenVisible,
readingMode,
readingDirection,
openChapter,
image,
]);
};

View File

@@ -18,6 +18,7 @@ import {
import { DEFAULT_READER_SETTINGS_WITH_DEFAULT_FLAG } from '@/modules/reader/services/ReaderSettingsMetadata.ts';
import { ReaderStatePages } from '@/modules/reader/types/ReaderProgressBar.types.ts';
import { TReaderOverlayContext } from '@/modules/reader/types/ReaderOverlay.types.ts';
import { READER_STATE_CHAPTERS_DEFAULTS } from '@/modules/reader/contexts/state/ReaderStateChaptersContext.tsx';
export const useReaderResetStates = (
setManga: TReaderStateMangaContext['setManga'],
@@ -36,10 +37,7 @@ export const useReaderResetStates = (
useEffect(
() => () => {
setManga(undefined);
setReaderStateChapters({
mangaChapters: [],
chapters: [],
});
setReaderStateChapters(READER_STATE_CHAPTERS_DEFAULTS);
setCurrentPageIndex(0);
setPageToScrollToIndex(null);

View File

@@ -12,10 +12,12 @@ import { DirectionOffset } from '@/Base.types.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { GetChaptersReaderQuery } from '@/lib/graphql/generated/graphql.ts';
import { IReaderSettings, ReaderStateChapters } from '@/modules/reader/types/Reader.types.ts';
import { READER_STATE_CHAPTERS_DEFAULTS } from '@/modules/reader/contexts/state/ReaderStateChaptersContext.tsx';
export const useReaderSetChaptersState = (
chaptersResponse: ReturnType<typeof requestManager.useGetMangaChapters<GetChaptersReaderQuery>>,
chapterSourceOrder: number,
initialChapter: ReaderStateChapters['initialChapter'],
chapterForDuplicatesHandling: ReaderStateChapters['chapterForDuplicatesHandling'],
setReaderStateChapters: ReaderStateChapters['setReaderStateChapters'],
shouldSkipDupChapters: IReaderSettings['shouldSkipDupChapters'],
@@ -25,32 +27,55 @@ export const useReaderSetChaptersState = (
const newCurrentChapter = newMangaChapters
? (newMangaChapters[newMangaChapters.length - chapterSourceOrder] ?? null)
: undefined;
const newInitialChapter = initialChapter ?? newCurrentChapter;
const newChapterForDuplicatesHandling = chapterForDuplicatesHandling ?? newCurrentChapter;
setReaderStateChapters({
mangaChapters: newMangaChapters ?? [],
chapters:
newChapterForDuplicatesHandling && newMangaChapters
? Chapters.removeDuplicates(newChapterForDuplicatesHandling, newMangaChapters)
: [],
chapterForDuplicatesHandling: newChapterForDuplicatesHandling,
currentChapter: newCurrentChapter,
nextChapter:
newMangaChapters &&
newCurrentChapter &&
Chapters.getNextChapter(newCurrentChapter, newMangaChapters, {
offset: DirectionOffset.NEXT,
skipDupe: shouldSkipDupChapters,
skipDupeChapter: newChapterForDuplicatesHandling,
}),
previousChapter:
newMangaChapters &&
newCurrentChapter &&
Chapters.getNextChapter(newCurrentChapter, newMangaChapters, {
offset: DirectionOffset.PREVIOUS,
skipDupe: shouldSkipDupChapters,
skipDupeChapter: newChapterForDuplicatesHandling,
}),
const nextChapter =
newMangaChapters &&
newCurrentChapter &&
Chapters.getNextChapter(newCurrentChapter, newMangaChapters, {
offset: DirectionOffset.NEXT,
skipDupe: shouldSkipDupChapters,
skipDupeChapter: newChapterForDuplicatesHandling,
});
const previousChapter =
newMangaChapters &&
newCurrentChapter &&
Chapters.getNextChapter(newCurrentChapter, newMangaChapters, {
offset: DirectionOffset.PREVIOUS,
skipDupe: shouldSkipDupChapters,
skipDupeChapter: newChapterForDuplicatesHandling,
});
const hasInitialChapterChanged = newInitialChapter != null && newInitialChapter.id !== initialChapter?.id;
setReaderStateChapters((prevState) => {
const hasCurrentChapterChanged = newCurrentChapter?.id !== prevState.currentChapter?.id;
return {
...prevState,
mangaChapters: newMangaChapters ?? [],
chapters:
newChapterForDuplicatesHandling && newMangaChapters
? Chapters.removeDuplicates(newChapterForDuplicatesHandling, newMangaChapters)
: [],
initialChapter: newInitialChapter,
chapterForDuplicatesHandling: newChapterForDuplicatesHandling,
currentChapter: newCurrentChapter,
nextChapter,
previousChapter,
isCurrentChapterReady: hasCurrentChapterChanged ? false : prevState.isCurrentChapterReady,
visibleChapters: hasInitialChapterChanged
? {
...READER_STATE_CHAPTERS_DEFAULTS.visibleChapters,
lastLeadingChapterSourceOrder:
newInitialChapter?.sourceOrder ??
READER_STATE_CHAPTERS_DEFAULTS.visibleChapters.lastLeadingChapterSourceOrder,
lastTrailingChapterSourceOrder:
newInitialChapter?.sourceOrder ??
READER_STATE_CHAPTERS_DEFAULTS.visibleChapters.lastTrailingChapterSourceOrder,
}
: prevState.visibleChapters,
};
});
}, [chaptersResponse.data?.chapters.nodes, chapterSourceOrder, shouldSkipDupChapters]);
}, [chaptersResponse.data?.chapters.nodes, chapterSourceOrder, shouldSkipDupChapters, initialChapter]);
};

View File

@@ -6,62 +6,78 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useEffect } from 'react';
import { useLayoutEffect, useRef } from 'react';
import { getInitialReaderPageIndex } from '@/modules/reader/utils/Reader.utils.ts';
import { createPagesData } from '@/modules/reader/utils/ReaderPager.utils.tsx';
import {
ReaderPageSpreadState,
ReaderResumeMode,
ReaderStateChapters,
ReaderTransitionPageMode,
} from '@/modules/reader/types/Reader.types.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { ReaderStatePages } from '@/modules/reader/types/ReaderProgressBar.types.ts';
import { READER_STATE_PAGES_DEFAULTS } from '@/modules/reader/constants/ReaderContext.constants.ts';
import { TChapterReader } from '@/modules/chapter/Chapter.types.ts';
export const useReaderSetPagesState = (
isCurrentChapter: boolean,
pagesResponse: ReturnType<typeof requestManager.useGetChapterPagesFetch>[1],
resumeMode: ReaderResumeMode,
currentChapter: ReaderStateChapters['currentChapter'],
lastPageRead: TChapterReader['lastPageRead'] | undefined,
pages: ReaderStatePages['pages'],
pageLoadStates: ReaderStatePages['pageLoadStates'],
pagesToSpreadState: ReaderPageSpreadState[],
arePagesFetched: boolean,
setArePagesFetched: (fetched: boolean) => void,
setReaderStateChapters: ReaderStateChapters['setReaderStateChapters'],
setTotalPages: ReaderStatePages['setTotalPages'],
setPages: ReaderStatePages['setPages'],
setPageUrls: ReaderStatePages['setPageUrls'],
setPageLoadStates: ReaderStatePages['setPageLoadStates'],
setPagesToSpreadState: (state: ReaderPageSpreadState[]) => void,
setCurrentPageIndex: ReaderStatePages['setCurrentPageIndex'],
setPageToScrollToIndex: ReaderStatePages['setPageToScrollToIndex'],
setTransitionPageMode: ReaderStatePages['setTransitionPageMode'],
) => {
useEffect(() => {
const previousPageData = useRef<string[]>();
useLayoutEffect(() => {
const pagesPayload = pagesResponse.data?.fetchChapterPages;
if (pagesPayload) {
const { pages } = pagesPayload;
const newPages = pages.length ? pages : [''];
if (!pagesPayload) {
return;
}
const initialReaderPageIndex = getInitialReaderPageIndex(
resumeMode,
currentChapter?.lastPageRead ?? 0,
newPages.length - 1,
);
const { pages: pagesFromResponse } = pagesPayload;
const newPages = pages.length ? pagesFromResponse : [''];
const initialReaderPageIndex = getInitialReaderPageIndex(resumeMode, lastPageRead ?? 0, newPages.length - 1);
const didPagesChange = previousPageData.current !== pagesPayload?.pages;
if (didPagesChange) {
previousPageData.current = pagesPayload.pages;
const newPageData = createPagesData(newPages);
setArePagesFetched(true);
setTotalPages(pagesPayload.chapter.pageCount);
setPages(newPageData);
setPageUrls(newPages);
setPageLoadStates(newPageData.map(({ primary: { url } }) => ({ url, loaded: false })));
setPagesToSpreadState(newPageData.map(({ primary: { url } }) => ({ url, isSpread: false })));
setCurrentPageIndex(initialReaderPageIndex);
setPageToScrollToIndex(initialReaderPageIndex);
} else {
setArePagesFetched(false);
setCurrentPageIndex(READER_STATE_PAGES_DEFAULTS.currentPageIndex);
setPageToScrollToIndex(READER_STATE_PAGES_DEFAULTS.pageToScrollToIndex);
setTotalPages(READER_STATE_PAGES_DEFAULTS.totalPages);
setPages(READER_STATE_PAGES_DEFAULTS.pages);
setPageUrls(READER_STATE_PAGES_DEFAULTS.pageUrls);
setPageLoadStates(READER_STATE_PAGES_DEFAULTS.pageLoadStates);
setPages(pages);
setPageLoadStates(pageLoadStates);
setPagesToSpreadState(pagesToSpreadState);
}
setTotalPages(pagesPayload.pages.length);
setPageUrls(newPages);
setCurrentPageIndex(initialReaderPageIndex);
setPageToScrollToIndex(initialReaderPageIndex);
setReaderStateChapters((prevState) => ({
...prevState,
isCurrentChapterReady: arePagesFetched || didPagesChange,
}));
setTransitionPageMode(ReaderTransitionPageMode.NONE);
}, [pagesResponse.data?.fetchChapterPages?.pages]);
}, [pagesResponse.data?.fetchChapterPages?.pages, isCurrentChapter]);
};