From 0b1c67ce9fd67bac489d55c63df3033b8b103d8c Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Sun, 3 Nov 2024 03:09:00 +0100 Subject: [PATCH] Reader hotkeys --- package.json | 1 + public/locales/en.json | 29 ++++ src/modules/core/Core.types.ts | 10 ++ src/modules/core/components/Kbd.tsx | 23 +++ .../buttons/ValueRotationButton.tsx | 9 +- src/modules/core/contexts/AppContext.tsx | 5 +- .../core/utils/ValueRotationButton.utils.ts | 23 +++ src/modules/hotkeys/Hotkeys.constants.ts | 13 ++ src/modules/hotkeys/Hotkeys.types.ts | 14 ++ src/modules/hotkeys/Hotkeys.utils.ts | 26 ++++ .../hotkeys/contexts/AppHotkeysProvider.tsx | 15 ++ src/modules/metadata/Metadata.constants.ts | 2 + .../reader/components/ReaderHotkeys.tsx | 131 ++++++++++++++++++ .../components/settings/ReaderSettings.tsx | 3 + .../settings/ReaderSettingsTabs.tsx | 15 ++ .../components/settings/hotkeys/Hotkey.tsx | 45 ++++++ .../hotkeys/ReaderHotkeysSettings.tsx | 46 ++++++ .../settings/hotkeys/ReaderSettingHotkey.tsx | 78 +++++++++++ .../settings/hotkeys/RecordHotkey.tsx | 91 ++++++++++++ .../settings/layout/ReaderSettingPageGap.tsx | 50 +++---- .../constants/ReaderSettings.constants.tsx | 32 +++++ src/modules/reader/services/ReaderControls.ts | 128 +++++++++++++++++ src/modules/reader/services/ReaderService.ts | 1 - .../reader/services/ReaderSettingsMetadata.ts | 3 + src/modules/reader/types/Reader.types.ts | 14 ++ yarn.lock | 5 + 26 files changed, 781 insertions(+), 31 deletions(-) create mode 100644 src/modules/core/components/Kbd.tsx create mode 100644 src/modules/core/utils/ValueRotationButton.utils.ts create mode 100644 src/modules/hotkeys/Hotkeys.constants.ts create mode 100644 src/modules/hotkeys/Hotkeys.types.ts create mode 100644 src/modules/hotkeys/Hotkeys.utils.ts create mode 100644 src/modules/hotkeys/contexts/AppHotkeysProvider.tsx create mode 100644 src/modules/reader/components/ReaderHotkeys.tsx create mode 100644 src/modules/reader/components/settings/hotkeys/Hotkey.tsx create mode 100644 src/modules/reader/components/settings/hotkeys/ReaderHotkeysSettings.tsx create mode 100644 src/modules/reader/components/settings/hotkeys/ReaderSettingHotkey.tsx create mode 100644 src/modules/reader/components/settings/hotkeys/RecordHotkey.tsx create mode 100644 src/modules/reader/services/ReaderControls.ts diff --git a/package.json b/package.json index b92b26b2..1eeb867d 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "react": "18.3.1", "react-beautiful-dnd": "13.1.1", "react-dom": "18.3.1", + "react-hotkeys-hook": "^4.6.1", "react-i18next": "15.0.1", "react-lazily": "0.9.2", "react-router-dom": "6.26.1", diff --git a/public/locales/en.json b/public/locales/en.json index 52ebcd47..eda6b6a7 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -496,6 +496,23 @@ }, "value": "{{value}}{{unit}}" }, + "hotkeys": { + "create": { + "dialog": { + "label": "Recorded keys: ", + "placeholder": "Press keys", + "title": "Record keybind" + }, + "error": { + "exists": "Hotkey already exists" + } + }, + "info": { + "delete": "Click key to remove binding" + }, + "title_one": "Keybind", + "title_other": "Keybinds" + }, "library": { "action": { "label": { @@ -838,6 +855,18 @@ } }, "exit_mode": "Open page on exit", + "hotkey": { + "menu": "Toggle menu", + "next_chapter": "Next chapter", + "next_page": "Next page", + "offset_spread_pages": "Toggle offset spread pages", + "previous_chapter": "Previous chapter", + "previous_page": "Previous page", + "scale_type": "Cycle image scale type", + "scroll_backward": "Scroll backward", + "scroll_forward": "Scroll forward", + "stretch_image": "Toggle stretch image" + }, "label": { "behaviour": "Behaviour", "fit_page_to_window": "Fit page to window", diff --git a/src/modules/core/Core.types.ts b/src/modules/core/Core.types.ts index 7068f233..d6bd67e1 100644 --- a/src/modules/core/Core.types.ts +++ b/src/modules/core/Core.types.ts @@ -47,3 +47,13 @@ export interface MultiValueButtonDefaultableProps export type MultiValueButtonProps = | (MultiValueButtonBaseProps & PropertiesNever>) | MultiValueButtonDefaultableProps; + +export enum ScrollOffset { + BACKWARD, + FORWARD, +} + +export enum ScrollDirection { + X, + Y, +} diff --git a/src/modules/core/components/Kbd.tsx b/src/modules/core/components/Kbd.tsx new file mode 100644 index 00000000..614c043b --- /dev/null +++ b/src/modules/core/components/Kbd.tsx @@ -0,0 +1,23 @@ +/* + * 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 { styled } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; + +export const Kbd = styled(Typography)(({ theme }) => ({ + display: 'inline-block', + padding: '0.2em 0.4em', + fontSize: '0.85em', + lineHeight: '1.4', + color: theme.palette.text.primary, + backgroundColor: theme.palette.background.paper, + border: `1px solid ${theme.palette.divider}`, + borderRadius: '3px', + boxShadow: `inset 0 -1px 0 ${theme.palette.divider}`, + fontFamily: 'monospace, monospace', +})); diff --git a/src/modules/core/components/buttons/ValueRotationButton.tsx b/src/modules/core/components/buttons/ValueRotationButton.tsx index c34c5a19..eb999438 100644 --- a/src/modules/core/components/buttons/ValueRotationButton.tsx +++ b/src/modules/core/components/buttons/ValueRotationButton.tsx @@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next'; import { ReactNode, useMemo } from 'react'; import Button from '@mui/material/Button'; import { MultiValueButtonProps } from '@/modules/core/Core.types.ts'; +import { getNextRotationValue } from '@/modules/core/utils/ValueRotationButton.utils.ts'; export const ValueRotationButton = ({ value, @@ -48,16 +49,14 @@ export const ValueRotationButton = ({ return ( + + + ); +}; diff --git a/src/modules/reader/components/settings/hotkeys/ReaderSettingHotkey.tsx b/src/modules/reader/components/settings/hotkeys/ReaderSettingHotkey.tsx new file mode 100644 index 00000000..0b637e4d --- /dev/null +++ b/src/modules/reader/components/settings/hotkeys/ReaderSettingHotkey.tsx @@ -0,0 +1,78 @@ +/* + * 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 Stack from '@mui/material/Stack'; +import { useTranslation } from 'react-i18next'; +import AddIcon from '@mui/icons-material/Add'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import Typography from '@mui/material/Typography'; +import Tooltip from '@mui/material/Tooltip'; +import { bindTrigger, usePopupState } from 'material-ui-popup-state/hooks'; +import IconButton from '@mui/material/IconButton'; +import { ReaderHotkey } from '@/modules/reader/types/Reader.types.ts'; +import { DEFAULT_READER_SETTINGS } from '@/modules/reader/constants/ReaderSettings.constants.tsx'; +import { TranslationKey } from '@/Base.types.ts'; +import { RecordHotkey } from '@/modules/reader/components/settings/hotkeys/RecordHotkey.tsx'; +import { Hotkey } from '@/modules/reader/components/settings/hotkeys/Hotkey.tsx'; + +const READER_HOTKEY_TO_TITLE: Record = { + [ReaderHotkey.PREVIOUS_PAGE]: 'reader.settings.hotkey.previous_page', + [ReaderHotkey.NEXT_PAGE]: 'reader.settings.hotkey.next_page', + [ReaderHotkey.SCROLL_BACKWARD]: 'reader.settings.hotkey.scroll_backward', + [ReaderHotkey.SCROLL_FORWARD]: 'reader.settings.hotkey.scroll_forward', + [ReaderHotkey.PREVIOUS_CHAPTER]: 'reader.settings.hotkey.previous_chapter', + [ReaderHotkey.NEXT_CHAPTER]: 'reader.settings.hotkey.next_chapter', + [ReaderHotkey.TOGGLE_MENU]: 'reader.settings.hotkey.menu', + [ReaderHotkey.CYCLE_SCALE_TYPE]: 'reader.settings.hotkey.scale_type', + [ReaderHotkey.STRETCH_IMAGE]: 'reader.settings.hotkey.stretch_image', + [ReaderHotkey.OFFSET_SPREAD_PAGES]: 'reader.settings.hotkey.offset_spread_pages', +}; + +export const ReaderSettingHotkey = ({ + hotkey, + keys, + existingKeys, + updateSetting, +}: { + hotkey: ReaderHotkey; + keys: string[]; + existingKeys: string[]; + updateSetting: (keys: string[]) => void; +}) => { + const { t } = useTranslation(); + const popupState = usePopupState({ popupId: 'reader-setting-record-hotkey', variant: 'dialog' }); + + return ( + <> + + {t(READER_HOTKEY_TO_TITLE[hotkey])} + updateSetting(keys.filter((key) => key !== keyToRemove))} + /> + + + + + + + updateSetting(DEFAULT_READER_SETTINGS.hotkeys[hotkey])} color="inherit"> + + + + + {popupState.isOpen && ( + updateSetting([...keys, ...recordedKeys])} + existingKeys={existingKeys} + /> + )} + + ); +}; diff --git a/src/modules/reader/components/settings/hotkeys/RecordHotkey.tsx b/src/modules/reader/components/settings/hotkeys/RecordHotkey.tsx new file mode 100644 index 00000000..5b821d76 --- /dev/null +++ b/src/modules/reader/components/settings/hotkeys/RecordHotkey.tsx @@ -0,0 +1,91 @@ +/* + * 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 { useEffect } from 'react'; +import Stack from '@mui/material/Stack'; +import { Trans, useTranslation } from 'react-i18next'; +import { useRecordHotkeys } from 'react-hotkeys-hook'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import { Hotkey } from '@/modules/reader/components/settings/hotkeys/Hotkey.tsx'; + +export const RecordHotkey = ({ + onClose, + onCreate, + existingKeys, +}: { + onClose: () => void; + onCreate: (keys: string[]) => void; + existingKeys: string[]; +}) => { + const { t } = useTranslation(); + + const [recordedKeys, { start, stop, resetKeys }] = useRecordHotkeys(); + const keys = [[...recordedKeys].join('+')]; + const isExistingKey = keys.some((key) => + existingKeys.map((existingKey) => existingKey.toLowerCase()).includes(key.toLowerCase()), + ); + + useEffect(() => { + start(); + + return () => { + stop(); + }; + }, []); + + return ( + + {t('hotkeys.create.dialog.title')} + + + + ) : ( + {t('hotkeys.create.dialog.placeholder')} + ), + }} + > + Recorded keys: + + + {isExistingKey && {t('hotkeys.create.error.exists')}} + + + + + + + + + + + + ); +}; diff --git a/src/modules/reader/components/settings/layout/ReaderSettingPageGap.tsx b/src/modules/reader/components/settings/layout/ReaderSettingPageGap.tsx index 00c7cbc2..c6b7e168 100644 --- a/src/modules/reader/components/settings/layout/ReaderSettingPageGap.tsx +++ b/src/modules/reader/components/settings/layout/ReaderSettingPageGap.tsx @@ -6,7 +6,6 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import Stack from '@mui/material/Stack'; import { useTranslation } from 'react-i18next'; import { IReaderSettingsWithDefaultFlag, ReadingMode } from '@/modules/reader/types/Reader.types.ts'; import { SliderInput } from '@/modules/core/components/inputs/SliderInput.tsx'; @@ -21,29 +20,32 @@ export const ReaderSettingPageGap = ({ }) => { const { t } = useTranslation(); + const isChangeable = [ReadingMode.CONTINUOUS_HORIZONTAL, ReadingMode.CONTINUOUS_VERTICAL].includes( + readingMode.value, + ); + if (!isChangeable) { + return null; + } + return ( - - {[ReadingMode.CONTINUOUS_HORIZONTAL, ReadingMode.CONTINUOUS_VERTICAL].includes(readingMode.value) && ( - { - updateSetting(value as number, false); - }, - onChangeCommitted: (_, value) => { - updateSetting(value as number, true); - }, - }, - }} - /> - )} - + { + updateSetting(value as number, false); + }, + onChangeCommitted: (_, value) => { + updateSetting(value as number, true); + }, + }, + }} + /> ); }; diff --git a/src/modules/reader/constants/ReaderSettings.constants.tsx b/src/modules/reader/constants/ReaderSettings.constants.tsx index 4b88c8cc..5d578673 100644 --- a/src/modules/reader/constants/ReaderSettings.constants.tsx +++ b/src/modules/reader/constants/ReaderSettings.constants.tsx @@ -21,6 +21,7 @@ import { ProgressBarType, ReaderBackgroundColor, ReaderExitMode, + ReaderHotkey, ReaderOverlayMode, ReaderPageScaleMode, ReadingDirection, @@ -53,6 +54,7 @@ const GLOBAL_READER_SETTING_OBJECT: Record = { @@ -191,6 +205,7 @@ export enum ReaderSettingTab { GENERAL, FILTER, BEHAVIOUR, + HOTKEYS, } export const READER_SETTING_TABS: Record< @@ -221,4 +236,21 @@ export const READER_SETTING_TABS: Record< label: 'reader.settings.label.behaviour', supportsTouchDevices: true, }, + [ReaderSettingTab.HOTKEYS]: { + id: ReaderSettingTab.HOTKEYS, + label: 'hotkeys.title_other', + supportsTouchDevices: false, + }, }; + +/** + * percentage values + */ +export enum ReaderScrollAmount { + SMALL = 25, + LARGE = 95, +} + +export const READER_HOTKEYS = Object.values(ReaderHotkey).filter( + (hotkey) => typeof hotkey === 'number', +) as ReaderHotkey[]; diff --git a/src/modules/reader/services/ReaderControls.ts b/src/modules/reader/services/ReaderControls.ts new file mode 100644 index 00000000..44079131 --- /dev/null +++ b/src/modules/reader/services/ReaderControls.ts @@ -0,0 +1,128 @@ +/* + * 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 { useCallback, useMemo } from 'react'; +import { Direction } from '@mui/material/styles'; +import { userReaderStatePagesContext } from '@/modules/reader/contexts/state/ReaderStatePagesContext.tsx'; +import { getNextPageIndex, getPage } from '@/modules/reader/utils/ReaderProgressBar.utils.tsx'; +import { getOptionForDirection } from '@/theme.tsx'; +import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; +import { useReaderStateChaptersContext } from '@/modules/reader/contexts/state/ReaderStateChaptersContext.tsx'; +import { ReadingDirection } from '@/modules/reader/types/Reader.types.ts'; +import { ScrollDirection, ScrollOffset } from '@/modules/core/Core.types.ts'; +import { ReaderScrollAmount } from '@/modules/reader/constants/ReaderSettings.constants.tsx'; + +const READING_DIRECTION_TO_DIRECTION: Record = { + [ReadingDirection.LTR]: 'ltr', + [ReadingDirection.RTL]: 'rtl', +}; + +const SCROLL_DIRECTION_BY_SCROLL_OFFSET_BY_READING_DIRECTION: Record> = { + [ReadingDirection.LTR]: { + [ScrollOffset.BACKWARD]: -1, + [ScrollOffset.FORWARD]: 1, + }, + [ReadingDirection.RTL]: { + [ScrollOffset.BACKWARD]: 1, + [ScrollOffset.FORWARD]: -1, + }, +}; + +export class ReaderControls { + static scroll( + offset: ScrollOffset, + direction: ScrollDirection, + readingDirection: ReadingDirection, + element: HTMLElement, + scrollAmountPercentage: number = ReaderScrollAmount.LARGE, + ): void { + if (!element) { + return; + } + + const scrollAmount = scrollAmountPercentage / 100; + const scrollDirection = SCROLL_DIRECTION_BY_SCROLL_OFFSET_BY_READING_DIRECTION[readingDirection][offset]; + + const getNewScrollPosition = (currentPos: number, elementSize: number) => + currentPos + elementSize * scrollAmount * scrollDirection; + + switch (direction) { + case ScrollDirection.X: + element.scroll({ + left: getNewScrollPosition(element.scrollLeft, element.clientWidth), + behavior: 'smooth', + }); + break; + case ScrollDirection.Y: + element.scroll({ + top: getNewScrollPosition(element.scrollTop, element.clientHeight), + behavior: 'smooth', + }); + break; + default: + throw new Error(`Unexpected "ScrollDirection" (${direction})`); + } + } + + static useOpenChapter(): (offset: 'previous' | 'next') => void { + const { readingDirection } = ReaderService.useSettings(); + const { previousChapter, nextChapter } = useReaderStateChaptersContext(); + const direction = READING_DIRECTION_TO_DIRECTION[readingDirection.value]; + + const openPreviousChapter = ReaderService.useNavigateToChapter(previousChapter); + const openNextChapter = ReaderService.useNavigateToChapter(nextChapter); + + return useCallback( + (offset) => { + switch (offset) { + case 'previous': + getOptionForDirection(openPreviousChapter, openNextChapter, direction)(); + break; + case 'next': + getOptionForDirection(openNextChapter, openPreviousChapter, direction)(); + break; + default: + throw new Error(`Unexpected "offset" (${offset})`); + } + }, + [direction, openPreviousChapter, openNextChapter], + ); + } + + static useOpenPage(): (offset: 'previous' | 'next') => void { + const { currentPageIndex, setCurrentPageIndex, pages } = userReaderStatePagesContext(); + const { readingDirection } = ReaderService.useSettings(); + + const currentPage = useMemo(() => getPage(currentPageIndex, pages), [currentPageIndex, pages]); + const previousPageIndex = useMemo( + () => getNextPageIndex('previous', currentPage.pagesIndex, pages), + [currentPage, pages], + ); + const nextPageIndex = useMemo( + () => getNextPageIndex('next', currentPage.pagesIndex, pages), + [currentPage, pages], + ); + const direction = READING_DIRECTION_TO_DIRECTION[readingDirection.value]; + + return useCallback( + (offset) => { + switch (offset) { + case 'previous': + setCurrentPageIndex(getOptionForDirection(previousPageIndex, nextPageIndex, direction)); + break; + case 'next': + setCurrentPageIndex(getOptionForDirection(nextPageIndex, previousPageIndex, direction)); + break; + default: + throw new Error(`Unexpected "offset" (${offset})`); + } + }, + [direction, previousPageIndex, nextPageIndex], + ); + } +} diff --git a/src/modules/reader/services/ReaderService.ts b/src/modules/reader/services/ReaderService.ts index 70ffec0f..eae31e88 100644 --- a/src/modules/reader/services/ReaderService.ts +++ b/src/modules/reader/services/ReaderService.ts @@ -46,7 +46,6 @@ const DIRECTION_TO_READING_DIRECTION: Record = { export class ReaderService { static useNavigateToChapter(chapter?: TChapterReader): () => void { const navigate = useNavigate(); - return useCallback(() => chapter && navigate(Chapters.getReaderUrl(chapter), { replace: true }), [chapter]); } diff --git a/src/modules/reader/services/ReaderSettingsMetadata.ts b/src/modules/reader/services/ReaderSettingsMetadata.ts index 441599fc..f3c35e6f 100644 --- a/src/modules/reader/services/ReaderSettingsMetadata.ts +++ b/src/modules/reader/services/ReaderSettingsMetadata.ts @@ -93,6 +93,7 @@ const convertSettingsToMetadata = ( readerWidth: JSON.stringify(settings.readerWidth), profiles: JSON.stringify(settings.profiles), readingModesDefaultProfile: JSON.stringify(settings.readingModesDefaultProfile), + hotkeys: JSON.stringify(settings.hotkeys), }); export const DEFAULT_READER_SETTINGS_WITH_DEFAULT_FLAG = convertToSettingsWithDefaultFlag( @@ -130,6 +131,8 @@ const convertMetadataToSettings = ( profiles.includes(profile) ? profile : DEFAULT_READER_PROFILE, ]), ) as IReaderSettings['readingModesDefaultProfile'], + hotkeys: + jsonSaveParse((metadata.hotkeys as string) ?? '') ?? defaultSettings.hotkeys, }; }; diff --git a/src/modules/reader/types/Reader.types.ts b/src/modules/reader/types/Reader.types.ts index 41a6a38b..e61a107b 100644 --- a/src/modules/reader/types/Reader.types.ts +++ b/src/modules/reader/types/Reader.types.ts @@ -119,6 +119,7 @@ export interface IReaderSettingsGlobal { backgroundColor: ReaderBackgroundColor; profiles: string[]; readingModesDefaultProfile: Record; + hotkeys: Record; } export interface IReaderSettingsManga { @@ -179,3 +180,16 @@ export type ReaderSettingsTypeProps = | ReaderSettingsTypeDefaultableProps | (PropertiesNever> & ReaderSettingsTypeNonDefaultableProps); + +export enum ReaderHotkey { + PREVIOUS_PAGE, + NEXT_PAGE, + SCROLL_BACKWARD, + SCROLL_FORWARD, + PREVIOUS_CHAPTER, + NEXT_CHAPTER, + TOGGLE_MENU, + CYCLE_SCALE_TYPE, + STRETCH_IMAGE, + OFFSET_SPREAD_PAGES, +} diff --git a/yarn.lock b/yarn.lock index 65dc0aea..bf409aee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6684,6 +6684,11 @@ react-dom@18.3.1: loose-envify "^1.1.0" scheduler "^0.23.2" +react-hotkeys-hook@^4.6.1: + version "4.6.1" + resolved "https://registry.yarnpkg.com/react-hotkeys-hook/-/react-hotkeys-hook-4.6.1.tgz#db9066c07377a1c8be067a238ab16e328266345a" + integrity sha512-XlZpbKUj9tkfgPgT9gA+1p7Ey6vFIZHttUjPqpTdyT5nqQ8mHL7elxvSbaC+dpSiHUSmr21Ya1mDxBZG3aje4Q== + react-i18next@15.0.1: version "15.0.1" resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-15.0.1.tgz#fc662d93829ecb39683fe2757a47ebfbc5c912a0"