diff --git a/src/components/reader/Page.tsx b/src/components/reader/Page.tsx index 2f1f422401..40ddb39c08 100644 --- a/src/components/reader/Page.tsx +++ b/src/components/reader/Page.tsx @@ -5,7 +5,7 @@ * 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 React, { useEffect, useRef } from 'react'; +import React, { useRef } from 'react'; import SpinnerImage from 'components/util/SpinnerImage'; import useLocalStorage from 'util/useLocalStorage'; import Box from '@mui/system/Box'; @@ -57,52 +57,18 @@ interface IProps { src: string index: number onImageLoad: () => void - setCurPage: React.Dispatch> settings: IReaderSettings } const Page = React.forwardRef((props: IProps, ref: any) => { const { - src, index, onImageLoad, setCurPage, settings, + src, index, onImageLoad, settings, } = props; const [useCache] = useLocalStorage('useCache', true); const imgRef = useRef(null); - const handleVerticalScroll = () => { - if (imgRef.current) { - const rect = imgRef.current.getBoundingClientRect(); - if (rect.y < 0 && rect.y + rect.height > 0) { - setCurPage(index); - } - } - }; - - const handleHorizontalScroll = () => { - if (imgRef.current) { - const rect = imgRef.current.getBoundingClientRect(); - if (rect.left <= window.innerWidth / 2 && rect.right > window.innerWidth / 2) { - setCurPage(index); - } - } - }; - - useEffect(() => { - switch (settings.readerType) { - case 'Webtoon': - case 'ContinuesVertical': - window.addEventListener('scroll', handleVerticalScroll); - return () => window.removeEventListener('scroll', handleVerticalScroll); - case 'ContinuesHorizontalLTR': - case 'ContinuesHorizontalRTL': - window.addEventListener('scroll', handleHorizontalScroll); - return () => window.removeEventListener('scroll', handleHorizontalScroll); - default: - return () => {}; - } - }, [handleVerticalScroll]); - const imgStyle = imageStyle(settings); return ( diff --git a/src/components/reader/pager/DoublePagedPager.tsx b/src/components/reader/pager/DoublePagedPager.tsx index 94d0ff1796..24ff8b822c 100644 --- a/src/components/reader/pager/DoublePagedPager.tsx +++ b/src/components/reader/pager/DoublePagedPager.tsx @@ -65,7 +65,6 @@ export default function DoublePagedPager(props: IReaderProps) { index={curPage} src={(pagesDisplayed.current === 1) ? pages[curPage].src : ''} onImageLoad={() => {}} - setCurPage={setCurPage} settings={settings} />, document.getElementById('display'), diff --git a/src/components/reader/pager/HorizontalPager.tsx b/src/components/reader/pager/HorizontalPager.tsx index da494d595c..13181e21c9 100644 --- a/src/components/reader/pager/HorizontalPager.tsx +++ b/src/components/reader/pager/HorizontalPager.tsx @@ -9,11 +9,26 @@ import React, { useEffect, useRef } from 'react'; import { Box } from '@mui/system'; import Page from '../Page'; +const findCurrentPageIndex = (wrapper: HTMLDivElement): number => { + for (let i = 0; i < wrapper.children.length; i++) { + const child = wrapper.children.item(i); + if (child) { + const { left, right } = child.getBoundingClientRect(); + if (left <= window.innerWidth / 2 && right > window.innerWidth / 2) return i; + } + } + return -1; +}; + +const isAtEnd = () => window.innerWidth + window.scrollX >= document.body.scrollWidth; +const isAtStart = () => window.scrollX <= 0; + export default function HorizontalPager(props: IReaderProps) { const { - pages, curPage, settings, setCurPage, prevChapter, nextChapter, + pages, curPage, initialPage, settings, setCurPage, prevChapter, nextChapter, } = props; + const currentPageRef = useRef(initialPage); const selfRef = useRef(null); const pagesRef = useRef([]); @@ -87,9 +102,12 @@ export default function HorizontalPager(props: IReaderProps) { }; useEffect(() => { - // scroll last read page into view after first mount - pagesRef.current[curPage]?.scrollIntoView({ inline: 'center' }); - }, [pagesRef.current.length]); + // Delay scrolling to next cycle + setTimeout(() => { + // scroll last read page into view when initialPage changes + pagesRef.current[initialPage]?.scrollIntoView({ inline: 'center' }); + }, 0); + }, [initialPage]); useEffect(() => { selfRef.current?.addEventListener('mousedown', dragControl); @@ -113,6 +131,30 @@ export default function HorizontalPager(props: IReaderProps) { }; }, [selfRef, curPage]); + useEffect(() => { + const handleScroll = () => { + if (!selfRef.current) return; + + // Update current page in parent + const currentPage = findCurrentPageIndex(selfRef.current); + if (currentPage !== currentPageRef.current) { + currentPageRef.current = currentPage; + setCurPage(currentPage); + } + + // Special case if scroll is moved all the way to the edge + // This handles cases when last page is show, but is smaller then + // window, in which case it would never get marked as read. + // See https://github.com/Suwayomi/Tachidesk-WebUI/issues/14 for more info + if (settings.readerType === 'ContinuesHorizontalLTR' ? isAtEnd() : isAtStart()) { + currentPageRef.current = pages.length - 1; + setCurPage(currentPageRef.current); + } + }; + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, [settings.readerType]); + return ( {}} - setCurPage={setCurPage} settings={settings} ref={(e:HTMLDivElement) => { pagesRef.current[page.index] = e; }} /> diff --git a/src/components/reader/pager/PagedPager.tsx b/src/components/reader/pager/PagedPager.tsx index 1fbe434eb4..d77022a2d0 100644 --- a/src/components/reader/pager/PagedPager.tsx +++ b/src/components/reader/pager/PagedPager.tsx @@ -100,7 +100,6 @@ export default function PagedReader(props: IReaderProps) { index={curPage} onImageLoad={() => {}} src={pages[curPage].src} - setCurPage={setCurPage} settings={settings} /> diff --git a/src/components/reader/pager/VerticalPager.tsx b/src/components/reader/pager/VerticalPager.tsx index 5a95e8979b..5a7c942a9d 100644 --- a/src/components/reader/pager/VerticalPager.tsx +++ b/src/components/reader/pager/VerticalPager.tsx @@ -5,92 +5,116 @@ * 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 React, { useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { Box } from '@mui/system'; import Page from '../Page'; -export default function VerticalReader(props: IReaderProps) { +const findCurrentPageIndex = (wrapper: HTMLDivElement): number => { + for (let i = 0; i < wrapper.children.length; i++) { + const child = wrapper.children.item(i); + if (child) { + const { top, bottom } = child.getBoundingClientRect(); + if (top <= window.innerHeight && bottom > 1) return i; + } + } + return -1; +}; + +// TODO: make configurable? +const SCROLL_OFFSET = 0.95; +const SCROLL_BEHAVIOR: ScrollBehavior = 'smooth'; + +const isAtBottom = () => window.innerHeight + window.scrollY >= document.body.offsetHeight; +const isAtTop = () => window.scrollY <= 0; + +export default function VerticalPager(props: IReaderProps) { const { - pages, settings, setCurPage, curPage, nextChapter, prevChapter, + pages, settings, setCurPage, initialPage, nextChapter, prevChapter, } = props; + const currentPageRef = useRef(initialPage); const selfRef = useRef(null); const pagesRef = useRef([]); useEffect(() => { - pagesRef.current = pagesRef.current.slice(0, pages.length); - }, [pages.length]); - - function nextPage() { - if (curPage < pages.length - 1) { - pagesRef.current[curPage + 1]?.scrollIntoView(); - setCurPage((page) => page + 1); - } else if (settings.loadNextonEnding) { - nextChapter(); - } - } + const handleScroll = () => { + if (!selfRef.current) return; + + if (isAtBottom()) { + // If scroll is moved all the way to the bottom + // This handles cases when last page is show, but is smaller then + // window, in which case it would never get marked as read. + // See https://github.com/Suwayomi/Tachidesk-WebUI/issues/14 for more info + currentPageRef.current = pages.length - 1; + setCurPage(currentPageRef.current); - function prevPage() { - if (curPage > 0) { - const rect = pagesRef.current[curPage].getBoundingClientRect(); - if (rect.y < 0 && rect.y + rect.height > 0) { - pagesRef.current[curPage]?.scrollIntoView(); + // Go to next chapter if configured to and at bottom + if (settings.loadNextonEnding) { + nextChapter(); + } } else { - pagesRef.current[curPage - 1]?.scrollIntoView(); - setCurPage(curPage - 1); + // Update current page in parent + const currentPage = findCurrentPageIndex(selfRef.current); + if (currentPage !== currentPageRef.current) { + currentPageRef.current = currentPage; + setCurPage(currentPage); + } } - } else if (curPage === 0) { - prevChapter(); - } - } + }; - function keyboardControl(e:KeyboardEvent) { - switch (e.code) { - case 'Space': - e.preventDefault(); - nextPage(); - break; - case 'ArrowRight': - nextPage(); - break; - case 'ArrowLeft': - prevPage(); - break; - default: - break; - } - } + window.addEventListener('scroll', handleScroll); + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, [settings.loadNextonEnding]); - function clickControl(e:MouseEvent) { - if (e.clientX > window.innerWidth / 2) { - nextPage(); - } else { - prevPage(); + const go = useCallback((direction: 'up' | 'down') => { + if (direction === 'down' && isAtBottom()) { + nextChapter(); + return; } - } - const handleLoadNextonEnding = () => { - if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) { - nextChapter(); + if (direction === 'up' && isAtTop()) { + prevChapter(); + return; } - }; + + window.scroll({ + top: window.scrollY + (window.innerHeight * SCROLL_OFFSET) * (direction === 'up' ? -1 : 1), + behavior: SCROLL_BEHAVIOR, + }); + }, [nextChapter, prevChapter]); useEffect(() => { - if (settings.loadNextonEnding) { document.addEventListener('scroll', handleLoadNextonEnding); } - document.addEventListener('keydown', keyboardControl, false); - selfRef.current?.addEventListener('click', clickControl); + const handleKeyboard = (e:KeyboardEvent) => { + switch (e.code) { + case 'Space': + case 'ArrowRight': + e.preventDefault(); + go('down'); + break; + case 'ArrowLeft': + e.preventDefault(); + go('up'); + break; + default: + break; + } + }; + document.addEventListener('keydown', handleKeyboard, false); return () => { - document.removeEventListener('scroll', handleLoadNextonEnding); - document.removeEventListener('keydown', keyboardControl); - selfRef.current?.removeEventListener('click', clickControl); + document.removeEventListener('keydown', handleKeyboard); }; - }, [selfRef, curPage]); + }, []); useEffect(() => { - // scroll last read page into view after first mount - pagesRef.current[curPage].scrollIntoView(); - }, [pagesRef.current.length]); + // Delay scrolling to next cycle + setTimeout(() => { + // scroll last read page into view when initialPage changes + pagesRef.current[initialPage]?.scrollIntoView(); + }, 0); + }, [initialPage]); return ( go(e.clientX > window.innerWidth / 2 ? 'down' : 'up')} > { pages.map((page) => ( @@ -110,7 +135,6 @@ export default function VerticalReader(props: IReaderProps) { index={page.index} src={page.src} onImageLoad={() => {}} - setCurPage={setCurPage} settings={settings} ref={(e:HTMLDivElement) => { pagesRef.current[page.index] = e; }} /> diff --git a/src/screens/Reader.tsx b/src/screens/Reader.tsx index 370f722aae..bf829359e5 100644 --- a/src/screens/Reader.tsx +++ b/src/screens/Reader.tsx @@ -6,7 +6,9 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import CircularProgress from '@mui/material/CircularProgress'; -import React, { useContext, useEffect, useState } from 'react'; +import React, { + useCallback, useContext, useEffect, useState, +} from 'react'; import { useHistory, useParams } from 'react-router-dom'; import HorizontalPager from 'components/reader/pager/HorizontalPager'; import PageNumber from 'components/reader/PageNumber'; @@ -50,6 +52,7 @@ const initialChapter = () => ({ pageCount: -1, index: -1, chapterCount: 0, + lastPageRead: 0, name: 'Loading...', }); @@ -62,7 +65,7 @@ export default function Reader() { const { chapterIndex, mangaId } = useParams<{ chapterIndex: string, mangaId: string }>(); const [manga, setManga] = useState({ id: +mangaId, title: '', thumbnailUrl: '' }); - const [chapter, setChapter] = useState(initialChapter()); + const [chapter, setChapter] = useState(initialChapter()); const [curPage, setCurPage] = useState(0); const { setOverride, setTitle } = useContext(NavbarContext); @@ -138,19 +141,7 @@ export default function Reader() { } }, [curPage]); - // return spinner while chpater data is loading - if (chapter.pageCount === -1) { - return ( - - - - ); - } - - const nextChapter = () => { + const nextChapter = useCallback(() => { if (chapter.index < chapter.chapterCount) { const formData = new FormData(); formData.append('lastPageRead', `${chapter.pageCount - 1}`); @@ -159,13 +150,25 @@ export default function Reader() { history.replace({ pathname: `/manga/${manga.id}/chapter/${chapter.index + 1}`, state: history.location.state }); } - }; + }, [chapter.index, chapter.chapterCount, chapter.pageCount, manga.id]); - const prevChapter = () => { + const prevChapter = useCallback(() => { if (chapter.index > 1) { history.replace({ pathname: `/manga/${manga.id}/chapter/${chapter.index - 1}`, state: history.location.state }); } - }; + }, [chapter.index, manga.id]); + + // return spinner while chpater data is loading + if (chapter.pageCount === -1) { + return ( + + + + ); + } const pages = range(chapter.pageCount).map((index) => ({ index, @@ -174,6 +177,9 @@ export default function Reader() { const ReaderComponent = getReaderComponent(settings.readerType); + // last page, also probably read = true, we will load the first page. + const initialPage = (chapter.lastPageRead === chapter.pageCount - 1) ? 0 : chapter.lastPageRead; + return ( > curPage: number + initialPage: number settings: IReaderSettings manga: IMangaCard | IManga - chapter: IChapter | IPartialChpter + chapter: IChapter | IPartialChapter nextChapter: () => void prevChapter: () => void }