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 (
+
+ );
+};
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"