diff --git a/public/locales/en.json b/public/locales/en.json index a4094eb5..9f2d0ad8 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -844,6 +844,11 @@ }, "settings": { "active_setting": "Active setting", + "auto_scroll": { + "smooth": "Smooth auto scrolling", + "speed": "Auto scroll speed", + "title": "Auto scroll" + }, "auto_webtoon_mode": { "description": "Automatically use webtoon mode for entries that are detected to likely use the long strip format", "title": "Auto webtoon mode" @@ -940,6 +945,7 @@ "title": "Tap zones" }, "title": { + "quick_settings": "Quick settings", "reader_settings": "Reader Settings" } }, diff --git a/src/modules/core/hooks/useAutomaticScrolling.ts b/src/modules/core/hooks/useAutomaticScrolling.ts new file mode 100644 index 00000000..a8f06e5b --- /dev/null +++ b/src/modules/core/hooks/useAutomaticScrolling.ts @@ -0,0 +1,160 @@ +/* + * 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 { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ScrollDirection } from '@/modules/core/Core.types.ts'; +import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx'; + +// in case the scroll amount is not enough "scrollBy" just does not do anything +const MIN_SCROLL_AMOUNT_PX = 1.5; +const getScrollAmount = (amountPerMs: number, speedMs: number): { amountPx: number; speedMs: number } => { + const amountPx = amountPerMs * speedMs; + + if (amountPx >= MIN_SCROLL_AMOUNT_PX) { + return { amountPx, speedMs }; + } + + return getScrollAmount(amountPerMs, speedMs + 1); +}; + +function getPxPerMs(size: number, scrollAmountPercentage: number, scrollSpeedMs: number) { + return (size * (scrollAmountPercentage / 100)) / scrollSpeedMs; +} + +export const useAutomaticScrolling = ( + refOrCallback: MutableRefObject | (() => void) | undefined, + scrollPerSecond: number, + scrollDirection: Exclude = ScrollDirection.Y, + scrollAmountPercentage: number = 100, + invert: boolean = false, + smooth: boolean = false, +): { + isActive: boolean; + isPaused: boolean; + start: () => void; + cancel: () => void; + toggleActive: () => void; + pause: () => void; + resume: () => void; +} => { + const isCallback = typeof refOrCallback === 'function'; + + const elementStyle = useRef(); + const scrollTriggerTimer = useRef(); + + const [isActive, setIsActive] = useState(false); + const [isPaused, setIsPaused] = useState(false); + + // scroll amount is based on the screen dimensions, thus, the hook needs to update in case they change + const [screenDimensions, setScreenDimensions] = useState({ width: window.innerWidth, height: window.innerHeight }); + useResizeObserver( + document.documentElement, + useCallback(() => { + const width = window.innerWidth; + const height = window.innerHeight; + + if (screenDimensions.width !== width || screenDimensions.height !== height) { + setScreenDimensions({ width, height }); + } + }, []), + ); + + const start = useCallback(() => { + setIsActive(true); + }, []); + const cancel = useCallback(() => { + setIsActive(false); + setIsPaused(false); + clearInterval(scrollTriggerTimer.current); + }, []); + const toggleActive = useCallback(() => { + if (isActive) { + cancel(); + return; + } + + start(); + }, [isActive]); + const pause = useCallback(() => { + setIsPaused(true); + }, []); + const resume = useCallback(() => { + setIsPaused(false); + }, []); + + useEffect(() => { + if (!refOrCallback) { + return () => {}; + } + + if (!isActive) { + return () => {}; + } + + const scrollSpeedMs = scrollPerSecond * 1000; + + const startScrolling = (callback: () => void, timeout: number) => { + clearInterval(scrollTriggerTimer.current); + scrollTriggerTimer.current = setInterval(() => { + if (isActive && !isPaused) { + callback(); + } + }, timeout); + }; + + if (isCallback) { + startScrolling(refOrCallback, scrollSpeedMs); + return () => {}; + } + + const element = refOrCallback.current; + if (!element) { + return () => {}; + } + + if (!elementStyle.current) { + elementStyle.current = getComputedStyle(element); + } + + const isRTL = elementStyle.current.direction; + const handleScrollX = scrollDirection !== ScrollDirection.Y; + const handleScrollY = scrollDirection !== ScrollDirection.X; + + const pxPerMsX = getPxPerMs(window.innerWidth, scrollAmountPercentage, scrollSpeedMs); + const pxPerMsY = getPxPerMs(window.innerHeight, scrollAmountPercentage, scrollSpeedMs); + + const tmpScrollSpeedMs = smooth ? 1 : scrollSpeedMs; + const { amountPx: pxScrollAmountX, speedMs: timeoutMsX } = getScrollAmount(pxPerMsX, tmpScrollSpeedMs); + const { amountPx: pxScrollAmountY, speedMs: timeoutMsY } = getScrollAmount(pxPerMsY, tmpScrollSpeedMs); + + const timeoutMs = handleScrollX ? timeoutMsX : timeoutMsY; + + const scrollTopByBase = handleScrollY ? pxScrollAmountX : 0; + const scrollLeftByBase = handleScrollX ? pxScrollAmountY : 0; + const scrollTopByReadingMode = scrollTopByBase; + const scrollLeftByReadingMode = isRTL ? -Math.abs(scrollLeftByBase) : scrollLeftByBase; + const scrollTopByInverted = invert ? -Math.abs(scrollTopByReadingMode) : scrollTopByReadingMode; + const scrollLeftByInverted = invert ? -Math.abs(scrollLeftByReadingMode) : scrollLeftByReadingMode; + + startScrolling(() => { + element.scrollBy({ + top: scrollTopByInverted, + left: scrollLeftByInverted, + // arg "smooth" triggers the interval so fast that using "behavior smooth" doesn't look smooth and also slows down the scrolling + behavior: smooth ? undefined : 'smooth', + }); + }, timeoutMs); + + return () => clearInterval(scrollTriggerTimer.current); + }, [refOrCallback, scrollPerSecond, scrollAmountPercentage, screenDimensions, isActive, isPaused]); + + return useMemo( + () => ({ isActive, isPaused, start, cancel, toggleActive, pause, resume }), + [isActive, isPaused, start, cancel, toggleActive, pause, resume], + ); +}; diff --git a/src/modules/metadata/Metadata.constants.ts b/src/modules/metadata/Metadata.constants.ts index 2afdca59..3799ca2a 100644 --- a/src/modules/metadata/Metadata.constants.ts +++ b/src/modules/metadata/Metadata.constants.ts @@ -69,6 +69,7 @@ const APP_METADATA_OBJECT: Record = { hotkeys: undefined, imagePreLoadAmount: undefined, shouldUseAutoWebtoonMode: undefined, + autoScroll: undefined, }; export const VALID_APP_METADATA_KEYS = Object.keys(APP_METADATA_OBJECT); diff --git a/src/modules/reader/components/ReaderHotkeys.tsx b/src/modules/reader/components/ReaderHotkeys.tsx index c90ec07c..2ebbbb7a 100644 --- a/src/modules/reader/components/ReaderHotkeys.tsx +++ b/src/modules/reader/components/ReaderHotkeys.tsx @@ -11,10 +11,11 @@ import { useEffect } from 'react'; import { useTheme } from '@mui/material/styles'; import { HOTKEY_SCOPES } from '@/modules/hotkeys/Hotkeys.constants.ts'; import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; -import { IReaderSettings, ReaderHotkey, ReadingMode } from '@/modules/reader/types/Reader.types.ts'; +import { IReaderSettings, ReaderHotkey } from '@/modules/reader/types/Reader.types.ts'; import { useReaderOverlayContext } from '@/modules/reader/contexts/ReaderOverlayContext.tsx'; import { getNextRotationValue } from '@/modules/core/utils/ValueRotationButton.utils.ts'; import { + CONTINUOUS_READING_MODE_TO_SCROLL_DIRECTION, READER_PAGE_SCALE_MODE_VALUES, ReaderScrollAmount, READING_DIRECTION_VALUES, @@ -23,9 +24,10 @@ import { import { useReaderStateMangaContext } from '@/modules/reader/contexts/state/ReaderStateMangaContext.tsx'; import { HotkeyScope } from '@/modules/hotkeys/Hotkeys.types.ts'; import { ReaderControls } from '@/modules/reader/services/ReaderControls.ts'; -import { ScrollDirection, ScrollOffset } from '@/modules/core/Core.types.ts'; +import { ScrollOffset } from '@/modules/core/Core.types.ts'; import { getOptionForDirection } from '@/modules/theme/services/ThemeCreator.ts'; import { FALLBACK_MANGA } from '@/modules/manga/Manga.constants.ts'; +import { useReaderAutoScrollContext } from '@/modules/reader/contexts/ReaderAutoScrollContext.tsx'; const useHotkeys = (...args: Parameters): ReturnType => { const [keys, callback, options, dependencies] = args; @@ -57,14 +59,6 @@ const updateSettingCycleThrough = ( updateSetting(setting, nextValue); }; -const CONTINUOUS_READING_MODE_TO_SCROLL_DIRECTION: Record = { - [ReadingMode.SINGLE_PAGE]: ScrollDirection.Y, - [ReadingMode.DOUBLE_PAGE]: ScrollDirection.Y, - [ReadingMode.CONTINUOUS_VERTICAL]: ScrollDirection.Y, - [ReadingMode.CONTINUOUS_HORIZONTAL]: ScrollDirection.X, - [ReadingMode.WEBTOON]: ScrollDirection.Y, -}; - export const ReaderHotkeys = ({ scrollElementRef, }: { @@ -77,6 +71,7 @@ export const ReaderHotkeys = ({ const { isVisible, setIsVisible: setIsOverlayVisible } = useReaderOverlayContext(); const { hotkeys, pageScaleMode, shouldStretchPage, shouldOffsetDoubleSpreads, readingMode, readingDirection } = ReaderService.useSettings(); + const automaticScrolling = useReaderAutoScrollContext(); const openChapter = ReaderControls.useOpenChapter(); const openPage = ReaderControls.useOpenPage(); @@ -188,6 +183,10 @@ export const ReaderHotkeys = ({ }, [updateSetting, deleteSetting, readingDirection.value, readingDirection.isDefault], ); + useHotkeys(hotkeys[ReaderHotkey.TOGGLE_AUTO_SCROLL], automaticScrolling.toggleActive, { preventDefault: true }, [ + updateSetting, + automaticScrolling.toggleActive, + ]); useEffect(() => { enableScope(HotkeyScope.READER); diff --git a/src/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopAutoScroll.tsx b/src/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopAutoScroll.tsx new file mode 100644 index 00000000..e801f196 --- /dev/null +++ b/src/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopAutoScroll.tsx @@ -0,0 +1,75 @@ +/* + * 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 { useTranslation } from 'react-i18next'; +import Button from '@mui/material/Button'; +import PauseCircleFilledIcon from '@mui/icons-material/PauseCircleFilled'; +import PlayCircleFilledIcon from '@mui/icons-material/PlayCircleFilled'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import InputAdornment from '@mui/material/InputAdornment'; +import { useRef } from 'react'; +import { useReaderAutoScrollContext } from '@/modules/reader/contexts/ReaderAutoScrollContext.tsx'; +import { IReaderSettings } from '@/modules/reader/types/Reader.types.ts'; +import { AUTO_SCROLL_SPEED } from '@/modules/reader/constants/ReaderSettings.constants.tsx'; + +export const ReaderNavBarDesktopAutoScroll = ({ + autoScroll, + setAutoScroll, +}: Pick & { + setAutoScroll: (newAutoScroll: IReaderSettings['autoScroll'], commit: boolean) => void; +}) => { + const { t } = useTranslation(); + const { isActive, toggleActive } = useReaderAutoScrollContext(); + + const updateTimeout = useRef(); + + return ( + + + { + const value = +e.target.value; + + if (value !== autoScroll.value) { + clearTimeout(updateTimeout.current); + setAutoScroll({ ...autoScroll, value }, true); + } + }} + onChange={(e) => { + const value = +e.target.value; + setAutoScroll({ ...autoScroll, value }, false); + + clearTimeout(updateTimeout.current); + updateTimeout.current = setTimeout(() => setAutoScroll({ ...autoScroll, value }, true), 1000); + }} + slotProps={{ + input: { + inputProps: AUTO_SCROLL_SPEED, + endAdornment: ( + + {t('global.time.seconds.second', { count: autoScroll.value })} + + ), + }, + }} + /> + + ); +}; diff --git a/src/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopQuickSettings.tsx b/src/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopQuickSettings.tsx index 8a4eb40d..d63e1d75 100644 --- a/src/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopQuickSettings.tsx +++ b/src/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopQuickSettings.tsx @@ -20,6 +20,7 @@ import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; import { withPropsFrom } from '@/modules/core/hoc/withPropsFrom.tsx'; import { useReaderStateMangaContext } from '@/modules/reader/contexts/state/ReaderStateMangaContext.tsx'; import { FALLBACK_MANGA } from '@/modules/manga/Manga.constants.ts'; +import { ReaderNavBarDesktopAutoScroll } from '@/modules/reader/components/overlay/navigation/desktop/quick-settings/ReaderNavBarDesktopAutoScroll.tsx'; const BaseReaderNavBarDesktopQuickSettings = ({ manga, @@ -28,12 +29,18 @@ const BaseReaderNavBarDesktopQuickSettings = ({ pageScaleMode, shouldStretchPage, readingDirection, + autoScroll, openSettings, }: Pick & Pick & Pick< IReaderSettingsWithDefaultFlag, - 'readingMode' | 'shouldOffsetDoubleSpreads' | 'pageScaleMode' | 'shouldStretchPage' | 'readingDirection' + | 'readingMode' + | 'shouldOffsetDoubleSpreads' + | 'pageScaleMode' + | 'shouldStretchPage' + | 'readingDirection' + | 'autoScroll' >) => { const { t } = useTranslation(); @@ -66,6 +73,10 @@ const BaseReaderNavBarDesktopQuickSettings = ({ isDefaultable onDefault={() => deleteSetting('readingDirection')} /> + updateSetting('autoScroll', ...args)} + />