diff --git a/public/locales/en.json b/public/locales/en.json index 47ac57ce..67280fb5 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -344,6 +344,11 @@ "stop": "Stop", "submit": "Submit" }, + "colors": { + "black": "Black", + "gray": "Gray", + "white": "White" + }, "date": { "label": { "day_one": "Day", @@ -389,22 +394,30 @@ "label": { "advanced": "Advanced", "are_you_sure": "Are you sure?", + "auto": "Auto", + "both": "Both", + "bottom": "Bottom", "browse": "Browse", "client": "Client", "close": "Close", "copied_clipboard": "Copied to clipboard", "default": "Default", + "desktop": "Desktop", "disabled": "Disabled", "discord": "Discord", "display": "Display", "filter": "Filter", "general": "General", "github": "GitHub", + "hidden": "Hidden", + "horizontal": "Horizontal", + "left": "Left", "links": "Links", "load_in_progress": "Still loading required data…", "loading": "Loading…", "logged_in": "Logged in", "menu": "Menu", + "mobile": "Mobile", "never": "Never", "next": "Next", "none": "None", @@ -413,12 +426,15 @@ "password": "Password", "placeholder": "Placeholder", "previous": "Previous", + "right": "Right", "share": "Share", "sort": "Sort", + "standard": "Standard", "started": "Started", "type": "Type", "unknown": "Unknown", - "username": "Username" + "username": "Username", + "vertical": "Vertical" }, "language": { "label": { @@ -453,6 +469,9 @@ "value": "{{count}} $t(global.time.seconds.second, lowercase)" } }, + "unit": { + "px": "px" + }, "update": { "label": { "available": "Update available", @@ -472,7 +491,8 @@ } } } - } + }, + "value": "{{value}}{{unit}}" }, "library": { "action": { @@ -779,16 +799,51 @@ } }, "settings": { + "background_color": "Background color", + "custom_filter": { + "brightness": { + "title": "Custom brightness", + "value": "$t(reader.settings.custom_filter.brightness.title)" + }, + "contrast": { + "title": "Custom contrast", + "value": "$t(reader.settings.custom_filter.contrast.title)" + }, + "grayscale": "Grayscale", + "hue": { + "title": "Custom hue", + "value": "$t(reader.settings.custom_filter.hue.title)" + }, + "invert": "Invert", + "rgba": { + "alpha": "Alpha", + "blue": "Blue", + "green": "Green", + "red": "Red", + "title": "Custom color filters" + }, + "saturate": { + "title": "Custom saturate", + "value": "$t(reader.settings.custom_filter.saturate.title)" + }, + "sepia": "Sepia", + "title": "Custom filter" + }, "error": { "label": { "failed_to_save_default_settings": "Could not save the default reader settings to the server", "failed_to_save_settings": "Could not save the reader settings to the server" } }, + "exit_mode": "Open page on exit", "label": { + "behaviour": "Behaviour", "fit_page_to_window": "Fit page to window", + "layout": "Layout", + "limit_reader_width": "Limit reader width", "load_next_chapter": "Load next chapter at ending", "offset_double_spread": "Offset double spreads", + "page_gap": "Page gap", "reader_type": "Reader type", "reader_width": "Reader width", "reading_direction": "Reading direction", @@ -798,13 +853,20 @@ "skip_dup_chapters": "Skip duplicate chapters", "static_navigation": "Static navigation" }, + "overlay_mode": "Overlay mode", "page_scale": { "height": "Fit height", "original": "Original size", "screen": "Fit screen", "stretch": "Stretch small pages", + "title": "Scale type", "width": "Fit width" }, + "progress_bar": { + "position": "Progress bar position", + "size": "Progress bar size", + "style": "Progress bar style" + }, "reader_type": { "label": { "continuous_horizontal": "Continuous horizontal", @@ -818,6 +880,14 @@ "ltr": "Left to right", "rtl": "Right to left" }, + "tap_zones": { + "edge": "Edge", + "invert": "Invert tap zones", + "kindle": "Kindle", + "l_shape": "L-Shape", + "right_left": "Right and left", + "title": "Tap zones" + }, "title": { "default_reader_settings": "Default Reader Settings", "reader_settings": "Reader Settings" diff --git a/src/App.tsx b/src/App.tsx index 1650d2f9..25818d61 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -57,6 +57,10 @@ const { LibraryDuplicates } = loadable( lazyLoadFallback, ); const { Appearance } = loadable(() => import('@/modules/settings/screens/Appearance.tsx'), lazyLoadFallback); +const { DefaultReaderSettings } = loadable( + () => import('@/modules/reader/screens/DefaultReaderSettings.tsx'), + lazyLoadFallback, +); if (process.env.NODE_ENV !== 'production') { // Adds messages only in a dev environment @@ -114,7 +118,7 @@ const MainApp = () => { } /> } /> } /> - + } /> } /> } /> diff --git a/src/UtilTypes.d.ts b/src/UtilTypes.d.ts index 72f5b2d5..a40f511b 100644 --- a/src/UtilTypes.d.ts +++ b/src/UtilTypes.d.ts @@ -26,6 +26,10 @@ type PropertiesNever = { [key in keyof T]?: never }; type OmitFirst = T extends [any, ...infer R] ? R : never; -type TupleUnion = { +type TupleUnion = { [S in U]: Exclude extends never ? [...R, S] : TupleUnion, [...R, S]>; }[U]; + +type TransformRecordToWithDefaultFlag> = { + [K in keyof T]: { value: T[K]; isDefault: boolean }; +}; diff --git a/src/assets/icons/svg/HiddenProgressBarIcon.tsx b/src/assets/icons/svg/HiddenProgressBarIcon.tsx new file mode 100644 index 00000000..12edef14 --- /dev/null +++ b/src/assets/icons/svg/HiddenProgressBarIcon.tsx @@ -0,0 +1,24 @@ +/* + * 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 SvgIcon from '@mui/material/SvgIcon'; + +export const HiddenProgressBarIcon = () => ( + + + + + +); diff --git a/src/assets/icons/svg/StandardProgressBarIcon.tsx b/src/assets/icons/svg/StandardProgressBarIcon.tsx new file mode 100644 index 00000000..b1783421 --- /dev/null +++ b/src/assets/icons/svg/StandardProgressBarIcon.tsx @@ -0,0 +1,24 @@ +/* + * 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 SvgIcon from '@mui/material/SvgIcon'; + +export const StandardProgressBarIcon = () => ( + + + + + +); diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index 791c5350..e0c13e85 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -27,7 +27,6 @@ import { } from '@apollo/client'; import { OperationVariables, Reference } from '@apollo/client/core'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { ReadFieldFunction } from '@apollo/client/cache/core/types/common'; import { IRestClient, RestClient } from '@/lib/requests/client/RestClient.ts'; import { GraphQLClient } from '@/lib/requests/client/GraphQLClient.ts'; import { @@ -327,6 +326,7 @@ import { SOURCE_META_FIELDS } from '@/lib/graphql/fragments/SourceFragments.ts'; import { CHAPTER_META_FIELDS } from '@/lib/graphql/fragments/ChapterFragments.ts'; import { MetadataMigrationSettings } from '@/modules/migration/Migration.types.ts'; import { MangaIdInfo } from '@/modules/manga/Manga.types.ts'; +import { updateMetadataList } from '@/modules/metadata/services/MetadataApolloCacheHandler.ts'; enum GQLMethod { QUERY = 'QUERY', @@ -427,24 +427,6 @@ export const SPECIAL_ED_SOURCES = { ], }; -const updateMetadata = ( - key: string, - existingMetas: Reference[] | undefined, - readField: ReadFieldFunction, - createMetaRef: () => Reference | undefined, -): (Reference | undefined)[] | undefined => { - if (!existingMetas) { - return existingMetas; - } - - const exists = existingMetas.some((metaRef: Reference) => readField('key', metaRef) === key); - if (exists) { - return existingMetas; - } - - return [...existingMetas, createMetaRef()]; -}; - // TODO - extract logic to reduce the size of this file... grew waaaaaaaaaaaaay too big peepoFat // TODO - correctly update cache after all mutations instead of refetching queries export class RequestManager { @@ -1599,7 +1581,7 @@ export class RequestManager { id: cache.identify({ __typename: 'SourceType', id: sourceId }), fields: { meta(existingMetas, { readField }) { - return updateMetadata(key, existingMetas, readField, () => + return updateMetadataList(key, existingMetas, readField, () => cache.writeFragment({ data: data!.setSourceMeta!.meta, fragment: SOURCE_META_FIELDS, @@ -1637,7 +1619,7 @@ export class RequestManager { }, }, update(cache) { - cache.evict({ id: cache.identify({ __typename: 'SourceMetaType', id: sourceId, key }) }); + cache.evict({ id: cache.identify({ __typename: 'SourceMetaType', sourceId, key }) }); }, ...options, }, @@ -2123,7 +2105,7 @@ export class RequestManager { id: cache.identify({ __typename: 'MangaType', id: mangaId }), fields: { meta(existingMetas, { readField }) { - return updateMetadata(key, existingMetas, readField, () => + return updateMetadataList(key, existingMetas, readField, () => cache.writeFragment({ data: data!.setMangaMeta!.meta, fragment: MANGA_META_FIELDS, @@ -2161,7 +2143,7 @@ export class RequestManager { }, }, update(cache) { - cache.evict({ id: cache.identify({ __typename: 'MangaMetaType', id: mangaId, key }) }); + cache.evict({ id: cache.identify({ __typename: 'MangaMetaType', mangaId, key }) }); }, ...options, }, @@ -2351,7 +2333,7 @@ export class RequestManager { id: cache.identify({ __typename: 'ChapterType', id: chapterId }), fields: { meta(existingMetas, { readField }) { - return updateMetadata(key, existingMetas, readField, () => + return updateMetadataList(key, existingMetas, readField, () => cache.writeFragment({ data: data!.setChapterMeta!.meta, fragment: CHAPTER_META_FIELDS, @@ -2389,7 +2371,7 @@ export class RequestManager { }, }, update(cache) { - cache.evict({ id: cache.identify({ __typename: 'ChapterMetaType', id: chapterId, key }) }); + cache.evict({ id: cache.identify({ __typename: 'ChapterMetaType', chapterId, key }) }); }, ...options, }, @@ -2630,7 +2612,7 @@ export class RequestManager { id: cache.identify({ __typename: 'CategoryType', id: categoryId }), fields: { meta(existingMetas, { readField }) { - return updateMetadata(key, existingMetas, readField, () => + return updateMetadataList(key, existingMetas, readField, () => cache.writeFragment({ data: data!.setCategoryMeta!.meta, fragment: CATEGORY_META_FIELDS, @@ -2668,7 +2650,7 @@ export class RequestManager { }, }, update(cache) { - cache.evict({ id: cache.identify({ __typename: 'CategoryMetaType', id: categoryId, key }) }); + cache.evict({ id: cache.identify({ __typename: 'CategoryMetaType', categoryId, key }) }); }, ...options, }, diff --git a/src/modules/core/Core.types.ts b/src/modules/core/Core.types.ts index 26b1bb64..01c1baac 100644 --- a/src/modules/core/Core.types.ts +++ b/src/modules/core/Core.types.ts @@ -22,3 +22,20 @@ export type ValueToDisplayData = Record< icon: ReactNode; } >; + +export interface MultiValueButtonBaseProps { + value: Value; + values: Value[]; + setValue: (value: Value) => void; + valueToDisplayData: ValueToDisplayData; +} + +export interface MultiValueButtonDefaultableProps + extends OptionalProperty, 'value'> { + isDefaultable?: boolean; + onDefault?: () => void; +} + +export type MultiValueButtonProps = + | (MultiValueButtonBaseProps & PropertiesNever>) + | MultiValueButtonDefaultableProps; diff --git a/src/modules/core/components/buttons/ButtonSelect.tsx b/src/modules/core/components/buttons/ButtonSelect.tsx index 60148c1f..92cb46ef 100644 --- a/src/modules/core/components/buttons/ButtonSelect.tsx +++ b/src/modules/core/components/buttons/ButtonSelect.tsx @@ -9,23 +9,25 @@ import Stack from '@mui/material/Stack'; import { useTranslation } from 'react-i18next'; import Button from '@mui/material/Button'; -import { ValueToDisplayData } from '@/modules/core/Core.types.ts'; +import { MultiValueButtonProps } from '@/modules/core/Core.types.ts'; export const ButtonSelect = ({ value, values, setValue, valueToDisplayData, -}: { - value: Value; - values: Value[]; - setValue: (value: Value) => void; - valueToDisplayData: ValueToDisplayData; -}) => { + isDefaultable, + onDefault, +}: MultiValueButtonProps) => { const { t } = useTranslation(); return ( + {isDefaultable && ( + + )} {values.map((displayValue) => ( + ); + } return ( + + ); +}; diff --git a/src/modules/reader/components/settings/layout/ReaderSettingTapZoneInvertMode.tsx b/src/modules/reader/components/settings/layout/ReaderSettingTapZoneInvertMode.tsx new file mode 100644 index 00000000..135d0148 --- /dev/null +++ b/src/modules/reader/components/settings/layout/ReaderSettingTapZoneInvertMode.tsx @@ -0,0 +1,102 @@ +/* + * 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 { MultiValueButtonDefaultableProps, ValueToDisplayData } from '@/modules/core/Core.types.ts'; +import { + IReaderSettings, + IReaderSettingsWithDefaultFlag, + ReadingDirection, +} from '@/modules/reader/types/Reader.types.ts'; +import { TapZoneInvertMode } from '@/modules/reader/types/TapZoneLayout.types.ts'; +import { ButtonSelectInput } from '@/modules/core/components/inputs/ButtonSelectInput.tsx'; + +enum TapZonesInvertOption { + NONE, + HORIZONTAL, + VERTICAL, + BOTH, +} + +const VALUE_TO_DISPLAY_DATA: ValueToDisplayData = { + [TapZonesInvertOption.NONE]: { + title: 'global.label.none', + icon: null, + }, + [TapZonesInvertOption.HORIZONTAL]: { + title: 'global.label.horizontal', + icon: null, + }, + [TapZonesInvertOption.VERTICAL]: { + title: 'global.label.vertical', + icon: null, + }, + [TapZonesInvertOption.BOTH]: { + title: 'global.label.both', + icon: null, + }, +}; + +const TAP_ZONES_INVERT_OPTION_VALUES = Object.values(TapZonesInvertOption).filter((value) => typeof value === 'number'); + +const TAP_ZONES_INVERT_OPTION_TO_SETTING: Record = { + [TapZonesInvertOption.NONE]: { + vertical: false, + horizontal: false, + }, + [TapZonesInvertOption.HORIZONTAL]: { + vertical: false, + horizontal: true, + }, + [TapZonesInvertOption.VERTICAL]: { + vertical: true, + horizontal: false, + }, + [TapZonesInvertOption.BOTH]: { + vertical: true, + horizontal: true, + }, +}; + +const convertTapZoneInvertModeToOption = ({ vertical, horizontal }: TapZoneInvertMode): TapZonesInvertOption => { + if (vertical && horizontal) { + return TapZonesInvertOption.BOTH; + } + + if (vertical) { + return TapZonesInvertOption.VERTICAL; + } + + if (horizontal) { + return TapZonesInvertOption.HORIZONTAL; + } + + return TapZonesInvertOption.NONE; +}; + +export const ReaderSettingTapZoneInvertMode = ({ + tapZoneInvertMode, + setTapZoneInvertMode, + ...buttonSelectInputProps +}: Pick & + Pick, 'isDefaultable' | 'onDefault'> & { + setTapZoneInvertMode: (invert: IReaderSettings['tapZoneInvertMode']) => void; + }) => { + const { t } = useTranslation(); + + return ( + setTapZoneInvertMode(TAP_ZONES_INVERT_OPTION_TO_SETTING[value])} + valueToDisplayData={VALUE_TO_DISPLAY_DATA} + /> + ); +}; diff --git a/src/modules/reader/components/settings/layout/ReaderSettingTapZoneLayout.tsx b/src/modules/reader/components/settings/layout/ReaderSettingTapZoneLayout.tsx new file mode 100644 index 00000000..e748f6ad --- /dev/null +++ b/src/modules/reader/components/settings/layout/ReaderSettingTapZoneLayout.tsx @@ -0,0 +1,56 @@ +/* + * 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 { TapZoneLayouts } from '@/modules/reader/types/TapZoneLayout.types.ts'; +import { MultiValueButtonDefaultableProps, ValueToDisplayData } from '@/modules/core/Core.types.ts'; +import { IReaderSettingsWithDefaultFlag, ReadingDirection } from '@/modules/reader/types/Reader.types.ts'; +import { ButtonSelectInput } from '@/modules/core/components/inputs/ButtonSelectInput.tsx'; + +const VALUE_TO_DISPLAY_DATA: ValueToDisplayData = { + [TapZoneLayouts.EDGE]: { + title: 'reader.settings.tap_zones.edge', + icon: null, + }, + [TapZoneLayouts.KINDLE]: { + title: 'reader.settings.tap_zones.kindle', + icon: null, + }, + [TapZoneLayouts.L_SHAPE]: { + title: 'reader.settings.tap_zones.l_shape', + icon: null, + }, + [TapZoneLayouts.RIGHT_LEFT]: { + title: 'reader.settings.tap_zones.right_left', + icon: null, + }, +}; + +const READER_TAP_ZONE_LAYOUT_VALUES = Object.values(TapZoneLayouts).filter((value) => typeof value === 'number'); + +export const ReaderSettingTapZoneLayout = ({ + tapZoneLayout, + setTapZoneLayout, + ...buttonSelectInputProps +}: Pick & + Pick, 'isDefaultable' | 'onDefault'> & { + setTapZoneLayout: (layout: TapZoneLayouts) => void; + }) => { + const { t } = useTranslation(); + + return ( + + ); +}; diff --git a/src/modules/reader/components/settings/layout/ReaderSettingWidth.tsx b/src/modules/reader/components/settings/layout/ReaderSettingWidth.tsx new file mode 100644 index 00000000..75905ee3 --- /dev/null +++ b/src/modules/reader/components/settings/layout/ReaderSettingWidth.tsx @@ -0,0 +1,61 @@ +/* + * 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 { IReaderSettings } from '@/modules/reader/types/Reader.types.ts'; +import { CheckboxInput } from '@/modules/core/components/inputs/CheckboxInput.tsx'; +import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; +import { SliderInput } from '@/modules/core/components/inputs/SliderInput.tsx'; +import { DEFAULT_READER_SETTINGS } from '@/modules/reader/constants/ReaderSettings.constants.tsx'; +import { isReaderWidthEditable } from '@/modules/reader/utils/ReaderSettings.utils.tsx'; + +export const ReaderSettingWidth = ({ + readerWidth, + pageScaleMode, + updateSetting, +}: Pick & { + updateSetting: (...args: OmitFirst>) => void; +}) => { + const { t } = useTranslation(); + + if (!isReaderWidthEditable(pageScaleMode)) { + return null; + } + + return ( + + updateSetting('readerWidth', { ...readerWidth, enabled: checked }, true)} + /> + {readerWidth.enabled && ( + { + updateSetting('readerWidth', { ...readerWidth, value: value as number }, false); + }, + onChangeCommitted: (_, value) => { + updateSetting('readerWidth', { ...readerWidth, value: value as number }, true); + }, + }, + }} + /> + )} + + ); +}; diff --git a/src/modules/reader/constants/Reader.constants.ts b/src/modules/reader/constants/Reader.constants.ts deleted file mode 100644 index af1e7ebe..00000000 --- a/src/modules/reader/constants/Reader.constants.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 { - IReaderSettings, - ProgressBarPosition, - ProgressBarType, - ReaderPageScaleMode, - ReadingDirection, - ReadingMode, -} from '@/modules/reader/types/Reader.types.ts'; -import { TapZoneLayouts } from '@/modules/reader/types/TapZoneLayout.types.ts'; - -export const DEFAULT_READER_SETTINGS: IReaderSettings = { - staticNav: false, - showPageNumber: true, - loadNextOnEnding: false, - skipDupChapters: true, - readerWidth: 50, - tapZoneLayout: TapZoneLayouts.RIGHT_LEFT, - tapZoneInvertMode: { vertical: false, horizontal: false }, - progressBarType: ProgressBarType.STANDARD, - progressBarSize: 4, - progressBarPosition: ProgressBarPosition.BOTTOM, - pageScaleMode: ReaderPageScaleMode.ORIGINAL, - shouldScalePage: false, - shouldOffsetDoubleSpreads: false, - readingDirection: ReadingDirection.LTR, - readingMode: ReadingMode.SINGLE_PAGE, -}; diff --git a/src/modules/reader/constants/ReaderProgressBar.constants.ts b/src/modules/reader/constants/ReaderProgressBar.constants.ts deleted file mode 100644 index dedb392a..00000000 --- a/src/modules/reader/constants/ReaderProgressBar.constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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 { TooltipProps } from '@mui/material/Tooltip'; -import { ProgressBarPosition } from '@/modules/reader/types/Reader.types.ts'; - -export const READER_PROGRESS_BAR_POSITION_TO_PLACEMENT: Record = { - [ProgressBarPosition.BOTTOM]: 'top', - [ProgressBarPosition.LEFT]: 'right', - [ProgressBarPosition.RIGHT]: 'left', -}; diff --git a/src/modules/reader/constants/ReaderSettings.constants.tsx b/src/modules/reader/constants/ReaderSettings.constants.tsx index 567d10b3..d66ea572 100644 --- a/src/modules/reader/constants/ReaderSettings.constants.tsx +++ b/src/modules/reader/constants/ReaderSettings.constants.tsx @@ -6,10 +6,206 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import ArrowCircleLeftIcon from '@mui/icons-material/ArrowCircleLeft'; +import ArrowCircleRightIcon from '@mui/icons-material/ArrowCircleRight'; +import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap'; +import ExpandIcon from '@mui/icons-material/Expand'; +import CropOriginalIcon from '@mui/icons-material/CropOriginal'; +import { TooltipProps } from '@mui/material/Tooltip'; import { Direction } from '@mui/material/styles'; -import { ReadingDirection } from '@/modules/reader/types/Reader.types.ts'; +import { ValueToDisplayData } from '@/modules/core/Core.types.ts'; +import { + IReaderSettings, + IReaderSettingsGlobal, + ProgressBarPosition, + ProgressBarType, + ReaderBackgroundColor, + ReaderExitMode, + ReaderOverlayMode, + ReaderPageScaleMode, + ReadingDirection, + ReadingMode, +} from '@/modules/reader/types/Reader.types.ts'; +import { SinglePageIcon } from '@/assets/icons/svg/SinglePageIcon.tsx'; +import { DoublePageIcon } from '@/assets/icons/svg/DoublePageIcon.tsx'; +import { ContinuousVerticalPageIcon } from '@/assets/icons/svg/ContinuousVerticalPageIcon.tsx'; +import { ContinuousHorizontalPageIcon } from '@/assets/icons/svg/ContinuousHorizontalPageIcon.tsx'; +import { TranslationKey } from '@/Base.types.ts'; +import { TapZoneLayouts } from '@/modules/reader/types/TapZoneLayout.types.ts'; export const READING_DIRECTION_TO_THEME_DIRECTION: Record = { [ReadingDirection.LTR]: 'ltr', [ReadingDirection.RTL]: 'rtl', }; + +export const GLOBAL_READER_SETTING_OBJECT: Record = { + overlayMode: undefined, + exitMode: undefined, + customFilter: undefined, + shouldSkipDupChapters: undefined, + progressBarType: undefined, + progressBarSize: undefined, + progressBarPosition: undefined, + shouldShowPageNumber: undefined, + isStaticNav: undefined, + backgroundColor: undefined, +}; + +export const GLOBAL_READER_SETTING_KEYS = Object.keys(GLOBAL_READER_SETTING_OBJECT); + +export const DEFAULT_READER_SETTINGS: IReaderSettings = { + readerWidth: { value: 50, enabled: false }, + overlayMode: ReaderOverlayMode.AUTO, + tapZoneLayout: TapZoneLayouts.RIGHT_LEFT, + tapZoneInvertMode: { vertical: false, horizontal: false }, + progressBarType: ProgressBarType.STANDARD, + progressBarSize: 4, + progressBarPosition: ProgressBarPosition.BOTTOM, + pageScaleMode: ReaderPageScaleMode.ORIGINAL, + shouldStretchPage: false, + shouldOffsetDoubleSpreads: false, + shouldSkipDupChapters: true, + shouldShowPageNumber: true, + isStaticNav: false, + readingDirection: ReadingDirection.LTR, + readingMode: ReadingMode.SINGLE_PAGE, + exitMode: ReaderExitMode.PREVIOUS, + backgroundColor: ReaderBackgroundColor.THEME, + customFilter: { + brightness: { + value: 100, + enabled: false, + }, + contrast: { + value: 100, + enabled: false, + }, + saturate: { + value: 100, + enabled: false, + }, + hue: { + value: 0, + enabled: false, + }, + rgba: { + value: { + red: 0, + green: 0, + blue: 0, + alpha: 0, + }, + enabled: false, + }, + sepia: false, + grayscale: false, + invert: false, + }, + pageGap: 5, +}; + +export const READER_PROGRESS_BAR_POSITION_TO_PLACEMENT: Record = { + [ProgressBarPosition.BOTTOM]: 'top', + [ProgressBarPosition.LEFT]: 'right', + [ProgressBarPosition.RIGHT]: 'left', +}; + +export const READING_DIRECTION_VALUE_TO_DISPLAY_DATA: ValueToDisplayData = { + [ReadingDirection.LTR]: { + title: 'reader.settings.reading_direction.ltr', + icon: , + }, + [ReadingDirection.RTL]: { + title: 'reader.settings.reading_direction.rtl', + icon: , + }, +}; + +export const READING_DIRECTION_VALUES = Object.values(ReadingDirection).filter((value) => typeof value === 'number'); + +export const PAGE_SCALE_VALUE_TO_DISPLAY_DATA: ValueToDisplayData = { + [ReaderPageScaleMode.WIDTH]: { + title: 'reader.settings.page_scale.width', + icon: , + }, + [ReaderPageScaleMode.HEIGHT]: { + title: 'reader.settings.page_scale.height', + icon: , + }, + [ReaderPageScaleMode.SCREEN]: { + title: 'reader.settings.page_scale.screen', + icon: , + }, + [ReaderPageScaleMode.ORIGINAL]: { + title: 'reader.settings.page_scale.original', + icon: , + }, +}; + +export const READER_PAGE_SCALE_MODE_VALUES = Object.values(ReaderPageScaleMode).filter( + (value) => typeof value === 'number', +); + +export const READER_PAGE_SCALE_MODE_TO_SCALING_ALLOWED: Record = { + [ReaderPageScaleMode.WIDTH]: true, + [ReaderPageScaleMode.HEIGHT]: true, + [ReaderPageScaleMode.SCREEN]: true, + [ReaderPageScaleMode.ORIGINAL]: false, +}; + +export const READING_MODE_VALUE_TO_DISPLAY_DATA: ValueToDisplayData = { + [ReadingMode.SINGLE_PAGE]: { + title: 'reader.settings.reader_type.label.single_page', + icon: , + }, + [ReadingMode.DOUBLE_PAGE]: { + title: 'reader.settings.reader_type.label.double_page', + icon: , + }, + [ReadingMode.CONTINUOUS_VERTICAL]: { + title: 'reader.settings.reader_type.label.continuous_vertical', + icon: , + }, + [ReadingMode.CONTINUOUS_HORIZONTAL]: { + title: 'reader.settings.reader_type.label.continuous_horizontal', + icon: , + }, +}; +export const READING_MODE_VALUES = Object.values(ReadingMode).filter((value) => typeof value === 'number'); + +export enum ReaderSettingTab { + LAYOUT, + GENERAL, + FILTER, + BEHAVIOUR, +} + +export const READER_SETTING_TABS: Record< + ReaderSettingTab, + { + id: ReaderSettingTab; + label: TranslationKey; + supportsTouchDevices: boolean; + } +> = { + [ReaderSettingTab.LAYOUT]: { + id: ReaderSettingTab.LAYOUT, + label: 'reader.settings.label.layout', + supportsTouchDevices: true, + }, + [ReaderSettingTab.GENERAL]: { + id: ReaderSettingTab.GENERAL, + label: 'global.label.general', + supportsTouchDevices: true, + }, + [ReaderSettingTab.FILTER]: { + id: ReaderSettingTab.FILTER, + label: 'reader.settings.custom_filter.title', + supportsTouchDevices: true, + }, + [ReaderSettingTab.BEHAVIOUR]: { + id: ReaderSettingTab.BEHAVIOUR, + label: 'reader.settings.label.behaviour', + supportsTouchDevices: true, + }, +}; diff --git a/src/modules/reader/screens/DefaultReaderSettings.tsx b/src/modules/reader/screens/DefaultReaderSettings.tsx new file mode 100644 index 00000000..6f0097b3 --- /dev/null +++ b/src/modules/reader/screens/DefaultReaderSettings.tsx @@ -0,0 +1,72 @@ +/* + * 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 { useLayoutEffect, useState } from 'react'; +import { useDefaultReaderSettingsWithDefaultFlag } from '@/modules/reader/services/ReaderSettingsMetadata.ts'; +import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx'; +import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx'; +import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; +import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; +import { IReaderSettings } from '@/modules/reader/types/Reader.types.ts'; +import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; +import { ReaderSettingsTabs } from '@/modules/reader/components/settings/ReaderSettingsTabs.tsx'; + +export const DefaultReaderSettings = () => { + const { t } = useTranslation(); + const { setTitle, setAction } = useNavBarContext(); + + useLayoutEffect(() => { + setTitle(t('reader.settings.title.default_reader_settings')); + setAction(null); + + return () => { + setTitle(''); + setAction(null); + }; + }, [t]); + + const [activeTab, setActiveTab] = useState(0); + + const { + settings, + request: { loading, error, refetch }, + } = useDefaultReaderSettingsWithDefaultFlag(); + + const updateSetting = ( + key: Setting, + value: IReaderSettings[Setting], + commit?: boolean, + ) => { + ReaderService.updateSetting({ id: -1 }, key, value, commit, true); + }; + + if (loading) { + return ; + } + + if (error) { + return ( + refetch().catch(defaultPromiseErrorHandler('DefaultReaderSettings::refetch'))} + /> + ); + } + + return ( + updateSetting(setting, value, commit)} + /> + ); +}; diff --git a/src/modules/reader/services/ReaderService.ts b/src/modules/reader/services/ReaderService.ts index d9ca542d..51f62a01 100644 --- a/src/modules/reader/services/ReaderService.ts +++ b/src/modules/reader/services/ReaderService.ts @@ -6,14 +6,35 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { Direction, useTheme } from '@mui/material/styles'; +import { t as translate } from 'i18next'; import { TChapterReader } from '@/modules/chapter/Chapter.types.ts'; import { Chapters } from '@/modules/chapter/services/Chapters.ts'; -import { IReaderSettings, ReadingDirection } from '@/modules/reader/types/Reader.types.ts'; +import { + IReaderSettings, + IReaderSettingsWithDefaultFlag, + ReaderExitMode, + ReaderOverlayMode, + ReadingDirection, +} from '@/modules/reader/types/Reader.types.ts'; import { useReaderStateMangaContext } from '@/modules/reader/contexts/state/ReaderStateMangaContext.tsx'; -import { getReaderSettingsFor, useDefaultReaderSettings } from '@/modules/reader/services/ReaderSettingsMetadata.ts'; +import { + updateReaderSettings, + useDefaultReaderSettings, + useGetReaderSettingsFor, +} from '@/modules/reader/services/ReaderSettingsMetadata.ts'; +import { MangaIdInfo } from '@/modules/manga/Manga.types.ts'; +import { getMetadataKey } from '@/modules/metadata/services/MetadataReader.ts'; +import { requestManager } from '@/lib/requests/RequestManager.ts'; +import { MANGA_META_FIELDS } from '@/lib/graphql/fragments/MangaFragments.ts'; +import { makeToast } from '@/modules/core/utils/Toast.ts'; +import { MediaQuery } from '@/modules/core/utils/MediaQuery.tsx'; +import { GLOBAL_METADATA } from '@/lib/graphql/fragments/Fragments.ts'; +import { updateMetadataList } from '@/modules/metadata/services/MetadataApolloCacheHandler.ts'; +import { useBackButton } from '@/modules/core/hooks/useBackButton.ts'; +import { GLOBAL_READER_SETTING_KEYS } from '@/modules/reader/constants/ReaderSettings.constants.tsx'; const DIRECTION_TO_INVERTED: Record = { ltr: 'rtl', @@ -25,6 +46,8 @@ const DIRECTION_TO_READING_DIRECTION: Record = { rtl: ReadingDirection.RTL, }; +const DEFAULT_MANGA: MangaIdInfo = { id: -1 }; + export class ReaderService { static useNavigateToChapter(chapter?: TChapterReader): () => void { const navigate = useNavigate(); @@ -32,21 +55,170 @@ export class ReaderService { return useCallback(() => chapter && navigate(Chapters.getReaderUrl(chapter), { replace: true }), [chapter]); } - static useSettings(): IReaderSettings { + static useSettings(): IReaderSettingsWithDefaultFlag { const { manga } = useReaderStateMangaContext(); const defaultReaderSettings = useDefaultReaderSettings(); - return useMemo( - () => getReaderSettingsFor(manga ?? { id: -1 }, defaultReaderSettings.settings), - [manga, defaultReaderSettings], - ); + + return useGetReaderSettingsFor(manga ?? DEFAULT_MANGA, defaultReaderSettings.settings); } static useGetThemeDirection(): Direction { const { direction } = useTheme(); const { readingDirection } = ReaderService.useSettings(); - return DIRECTION_TO_READING_DIRECTION[direction] === readingDirection + return DIRECTION_TO_READING_DIRECTION[direction] === readingDirection.value ? direction : DIRECTION_TO_INVERTED[direction]; } + + /** + * Writes the change immediately to the cache and sends a mutation in case "commit" is true. + */ + static updateSetting( + manga: MangaIdInfo, + setting: Setting, + value: IReaderSettings[Setting], + commit: boolean = true, + isGlobal: boolean = false, + ): void { + if (!manga) { + return; + } + + const key = getMetadataKey(setting); + + const { cache } = requestManager.graphQLClient.client; + + const isGlobalSetting = isGlobal || GLOBAL_READER_SETTING_KEYS.includes(setting); + if (isGlobalSetting) { + const reference = cache.writeFragment({ + fragment: GLOBAL_METADATA, + data: { + __typename: 'GlobalMetaType', + key, + value: JSON.stringify(value), + }, + }); + cache.modify({ + fields: { + metas(existingMetas, { readField }) { + return { + ...existingMetas, + nodes: updateMetadataList(key, existingMetas?.nodes, readField, () => reference), + }; + }, + }, + }); + } else { + const reference = cache.writeFragment({ + fragment: MANGA_META_FIELDS, + data: { + __typename: 'MangaMetaType', + mangaId: manga.id, + key, + value: JSON.stringify(value), + }, + }); + cache.modify({ + id: cache.identify({ __typename: 'MangaType', id: manga.id }), + fields: { + meta(existingMetas, { readField }) { + return updateMetadataList(key, existingMetas, readField, () => reference); + }, + }, + }); + } + + if (commit) { + updateReaderSettings(manga, setting, value, isGlobal).catch(() => + makeToast(translate('reader.settings.error.label.failed_to_save_settings'), 'error'), + ); + } + } + + static deleteSetting( + manga: MangaIdInfo, + setting: Setting, + isGlobal: boolean = false, + ): void { + if (!manga) { + return; + } + + const key = getMetadataKey(setting); + + const { cache } = requestManager.graphQLClient.client; + + const isGlobalSetting = isGlobal || GLOBAL_READER_SETTING_KEYS.includes(setting); + if (isGlobalSetting) { + cache.evict({ id: cache.identify({ __typename: 'GlobalMetaType', key }) }); + } else { + cache.evict({ id: cache.identify({ __typename: 'MangaMetaType', mangaId: manga.id, key }) }); + } + + const deleteSetting = isGlobalSetting + ? () => requestManager.deleteGlobalMeta(key).response + : () => requestManager.deleteMangaMeta(manga.id, key).response; + deleteSetting().catch(() => + makeToast(translate('reader.settings.error.label.failed_to_save_settings'), 'error'), + ); + } + + static useCreateUpdateSetting( + manga: MangaIdInfo, + ): ( + ...args: OmitFirst>> + ) => ReturnType> { + return useCallback((...args) => this.updateSetting(manga, ...args), [manga]); + } + + static useCreateDeleteSetting( + manga: MangaIdInfo, + ): ( + ...args: OmitFirst>> + ) => ReturnType> { + return useCallback((...args) => this.deleteSetting(manga, ...args), [manga]); + } + + static useOverlayMode(): { mode: ReaderOverlayMode; isDesktop: boolean; isMobile: boolean } { + const isTouchDevice = MediaQuery.useIsTouchDevice(); + const { overlayMode } = this.useSettings(); + + const isAutoModeSelected = overlayMode === ReaderOverlayMode.AUTO; + const isDesktopModeSelected = overlayMode === ReaderOverlayMode.DESKTOP; + const isMobileModeSelected = overlayMode === ReaderOverlayMode.MOBILE; + + const isDesktop = isDesktopModeSelected || (isAutoModeSelected && !isTouchDevice); + const isMobile = isMobileModeSelected || (isAutoModeSelected && isTouchDevice); + + return { + mode: isDesktop ? ReaderOverlayMode.DESKTOP : ReaderOverlayMode.MOBILE, + isDesktop, + isMobile, + }; + } + + static useExit(): () => void { + const { manga } = useReaderStateMangaContext(); + const { exitMode } = this.useSettings(); + const handleBack = useBackButton(); + const navigate = useNavigate(); + + const openMangaPage = useCallback(() => { + if (!manga) { + return () => {}; + } + + return navigate(`/manga/${manga.id}`); + }, [manga]); + + switch (exitMode) { + case ReaderExitMode.PREVIOUS: + return handleBack; + case ReaderExitMode.MANGA: + return openMangaPage; + default: + throw new Error(`Unexpected "exitMode" (${exitMode})`); + } + } } diff --git a/src/modules/reader/services/ReaderSettingsMetadata.ts b/src/modules/reader/services/ReaderSettingsMetadata.ts index 5f07d98b..9d6d7723 100644 --- a/src/modules/reader/services/ReaderSettingsMetadata.ts +++ b/src/modules/reader/services/ReaderSettingsMetadata.ts @@ -8,12 +8,14 @@ import { useEffect, useMemo } from 'react'; import { requestManager } from '@/lib/requests/RequestManager.ts'; -import { requestUpdateMangaMetadata } from '@/modules/metadata/services/MetadataUpdater.ts'; +import { + requestUpdateMangaMetadata, + requestUpdateServerMetadata, +} from '@/modules/metadata/services/MetadataUpdater.ts'; import { MangaType } from '@/lib/graphql/generated/graphql.ts'; -import { DEFAULT_READER_SETTINGS } from '@/modules/reader/constants/Reader.constants.ts'; -import { IReaderSettings } from '@/modules/reader/types/Reader.types.ts'; +import { IReaderSettings, IReaderSettingsWithDefaultFlag } from '@/modules/reader/types/Reader.types.ts'; import { convertFromGqlMeta } from '@/modules/metadata/services/MetadataConverter.ts'; -import { getMetadataFrom } from '@/modules/metadata/services/MetadataReader.ts'; +import { extractOriginalKey, getMetadataFrom } from '@/modules/metadata/services/MetadataReader.ts'; import { AllowedMetadataValueTypes, AppMetadataKeys, @@ -25,14 +27,69 @@ import { import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { jsonSaveParse } from '@/lib/HelperFunctions.ts'; import { MangaIdInfo } from '@/modules/manga/Manga.types.ts'; +import { + DEFAULT_READER_SETTINGS, + GLOBAL_READER_SETTING_KEYS, +} from '@/modules/reader/constants/ReaderSettings.constants.tsx'; +import { DEFAULT_DEVICE, getActiveDevice } from '@/modules/device/services/Device.ts'; +import { APP_METADATA_KEY_PREFIX } from '@/modules/metadata/Metadata.constants.ts'; + +const convertToSettingsWithDefaultFlag = ( + type: Extract, + settings: IReaderSettings, + metadataHolder: MetadataHolder, +): IReaderSettingsWithDefaultFlag => { + const activeDevice = getActiveDevice(); + const istDefaultDevice = activeDevice === DEFAULT_DEVICE; + const existingSettings = Object.keys(metadataHolder.meta ?? {}) + // settings that are not for the active device need to be filtered out, otherwise, they mess up the "isDefault" flag + .filter((metaKey) => { + // the default device is not added as a prefix to the key, thus, there should only be the app prefix for these reader settings + if (istDefaultDevice) { + return metaKey.match(/_/g)?.length === 1; + } + + return metaKey.startsWith(`${APP_METADATA_KEY_PREFIX}_${activeDevice}_`); + }) + .map((metaKey) => extractOriginalKey(metaKey)); + const settingsWithDefault = Object.fromEntries( + (Object.entries(settings) as [keyof IReaderSettings, IReaderSettings[keyof IReaderSettings]][]).map( + ([key, value]) => { + const isGlobalSetting = GLOBAL_READER_SETTING_KEYS.includes(key); + if (isGlobalSetting) { + return [key, value]; + } + + const isDefaultSetting = type === 'manga' && !existingSettings.includes(key); + return [ + key, + { + value, + isDefault: isDefaultSetting, + }, + ]; + }, + ), + ) as IReaderSettingsWithDefaultFlag; + + return settingsWithDefault; +}; const convertSettingsToMetadata = ( settings: Partial, ): Metadata => ({ ...settings, tapZoneInvertMode: JSON.stringify(settings.tapZoneInvertMode), + customFilter: JSON.stringify(settings.customFilter), + readerWidth: JSON.stringify(settings.readerWidth), }); +export const DEFAULT_READER_SETTINGS_WITH_DEFAULT_FLAG = convertToSettingsWithDefaultFlag( + 'global', + DEFAULT_READER_SETTINGS, + { meta: convertSettingsToMetadata(DEFAULT_READER_SETTINGS) as Metadata }, +); + const convertMetadataToSettings = ( metadata: Partial>, defaultSettings: IReaderSettings, @@ -41,27 +98,21 @@ const convertMetadataToSettings = ( tapZoneInvertMode: jsonSaveParse((metadata.tapZoneInvertMode as string) ?? '') ?? defaultSettings.tapZoneInvertMode, + customFilter: + jsonSaveParse((metadata.customFilter as string) ?? '') ?? + defaultSettings.customFilter, + readerWidth: + jsonSaveParse((metadata.readerWidth as string) ?? '') ?? + defaultSettings.readerWidth, }); -function getReaderSettingsWithDefaultValueFallback( - type: 'global', - metadataHolder: MetadataHolder, - defaultSettings?: DefaultSettings, - useEffectFn?: typeof useEffect, -): DefaultSettings; -function getReaderSettingsWithDefaultValueFallback( - type: 'manga', - metadataHolder: MangaIdInfo & MetadataHolder, - defaultSettings?: DefaultSettings, - useEffectFn?: typeof useEffect, -): DefaultSettings; -function getReaderSettingsWithDefaultValueFallback( +const getReaderMetadata = ( type: Extract, metadataHolder: (MangaIdInfo & MetadataHolder) | MetadataHolder, - defaultSettings: DefaultSettings = DEFAULT_READER_SETTINGS as DefaultSettings, + defaultSettings: IReaderSettings = DEFAULT_READER_SETTINGS, useEffectFn?: typeof useEffect, -): DefaultSettings { - return convertMetadataToSettings( +) => + convertMetadataToSettings( getMetadataFrom( type as Parameters[0], metadataHolder as Parameters[1], @@ -70,7 +121,28 @@ function getReaderSettingsWithDefaultValueFallback, + metadataHolder: (MangaIdInfo & MetadataHolder) | MetadataHolder, + defaultSettings: IReaderSettings = DEFAULT_READER_SETTINGS, + useEffectFn?: typeof useEffect, +): IReaderSettingsWithDefaultFlag { + const settings = getReaderMetadata(type, metadataHolder, defaultSettings, useEffectFn); + return convertToSettingsWithDefaultFlag(type, settings, metadataHolder); } const getSettings = ( @@ -82,7 +154,10 @@ const getSettings = ( 'manga', { ...metaHolder, - meta: convertFromGqlMeta(metaHolder.meta), + meta: convertFromGqlMeta( + metaHolder.meta, + (key) => !GLOBAL_READER_SETTING_KEYS.includes(extractOriginalKey(key)), + ), }, defaultSettings, useEffectFn, @@ -91,12 +166,12 @@ const getSettings = ( export const getReaderSettingsFor = ( metaHolder: MangaIdInfo & GqlMetaHolder, defaultSettings: IReaderSettings, -): IReaderSettings => getSettings(metaHolder, defaultSettings); +): IReaderSettingsWithDefaultFlag => getSettings(metaHolder, defaultSettings); export const useGetReaderSettingsFor = ( metaHolder: MangaIdInfo & GqlMetaHolder, defaultSettings: IReaderSettings, -): IReaderSettings => { +): IReaderSettingsWithDefaultFlag => { const settings = getSettings(metaHolder, defaultSettings, useEffect); return useMemo(() => settings, [metaHolder, defaultSettings]); }; @@ -106,6 +181,30 @@ export const useDefaultReaderSettings = (): { settings: IReaderSettings; loading: boolean; request: ReturnType; +} => { + const request = requestManager.useGetGlobalMeta({ notifyOnNetworkStatusChange: true }); + const { data, loading } = request; + const metadata = useMemo(() => convertFromGqlMeta(data?.metas.nodes), [data?.metas.nodes]); + const metaHolder: MetadataHolder = useMemo(() => ({ meta: metadata }), [metadata]); + const tmpSettings = getReaderMetadata('global', metaHolder, undefined, useEffect); + const settings = useMemo(() => tmpSettings, [metaHolder]); + + return useMemo( + () => ({ + metadata, + settings, + loading, + request, + }), + [metadata, settings, loading, request], + ); +}; + +export const useDefaultReaderSettingsWithDefaultFlag = (): { + metadata?: Metadata; + settings: IReaderSettingsWithDefaultFlag; + loading: boolean; + request: ReturnType; } => { const request = requestManager.useGetGlobalMeta({ notifyOnNetworkStatusChange: true }); const { data, loading } = request; @@ -129,13 +228,19 @@ export const updateReaderSettings = async & GqlMetaHolder, setting: Setting, value: IReaderSettings[Setting], -): Promise => - requestUpdateMangaMetadata(manga, [[setting, convertSettingsToMetadata({ [setting]: value })[setting]]]); + isGlobal: boolean = false, +): Promise => { + const isGlobalSetting = isGlobal || GLOBAL_READER_SETTING_KEYS.includes(setting); + if (isGlobalSetting) { + return requestUpdateServerMetadata([[setting, convertSettingsToMetadata({ [setting]: value })[setting]]]); + } + return requestUpdateMangaMetadata(manga, [[setting, convertSettingsToMetadata({ [setting]: value })[setting]]]); +}; export const createUpdateReaderSettings = ( manga: Pick & GqlMetaHolder, handleError: (error: any) => void = defaultPromiseErrorHandler('createUpdateReaderSettings'), ): ((...args: OmitFirst>>) => Promise) => - (setting, value) => - updateReaderSettings(manga, setting, value).catch(handleError); + (setting, value, isGlobal) => + updateReaderSettings(manga, setting, value, isGlobal).catch(handleError); diff --git a/src/modules/reader/types/Reader.types.ts b/src/modules/reader/types/Reader.types.ts index e9f9d730..7d72d611 100644 --- a/src/modules/reader/types/Reader.types.ts +++ b/src/modules/reader/types/Reader.types.ts @@ -8,6 +8,7 @@ import { TapZoneInvertMode, TapZoneLayouts } from '@/modules/reader/types/TapZoneLayout.types.ts'; import { TChapterReader } from '@/modules/chapter/Chapter.types.ts'; +import { ReaderService } from '@/modules/reader/services/ReaderService.ts'; export enum ProgressBarType { HIDDEN, @@ -39,6 +40,112 @@ export enum ReaderPageScaleMode { ORIGINAL, } +export enum ReaderOverlayMode { + AUTO, + DESKTOP, + MOBILE, +} + +export enum ReaderExitMode { + PREVIOUS, + MANGA, +} + +export enum ReaderBackgroundColor { + THEME, + BLACK, + GRAY, + WHITE, +} + +export interface ReaderFilterRGBA { + red: number; + green: number; + blue: number; + alpha: number; +} + +export interface ReaderCustomFilter { + brightness: { + /** + * percentage + */ + value: number; + enabled: boolean; + }; + contrast: { + /** + * percentage + */ + value: number; + enabled: boolean; + }; + saturate: { + /** + * percentage + */ + value: number; + enabled: boolean; + }; + hue: { + /** + * degree + */ + value: number; + enabled: boolean; + }; + rgba: { + value: ReaderFilterRGBA; + enabled: boolean; + }; + sepia: boolean; + grayscale: boolean; + invert: boolean; +} + +export interface IReaderSettingsGlobal { + overlayMode: ReaderOverlayMode; + exitMode: ReaderExitMode; + customFilter: ReaderCustomFilter; + shouldSkipDupChapters: boolean; + progressBarType: ProgressBarType; + /** + * pixel + */ + progressBarSize: number; + progressBarPosition: ProgressBarPosition; + shouldShowPageNumber: boolean; + isStaticNav: boolean; + backgroundColor: ReaderBackgroundColor; +} + +export interface IReaderSettingsManga { + tapZoneLayout: TapZoneLayouts; + tapZoneInvertMode: TapZoneInvertMode; + pageScaleMode: ReaderPageScaleMode; + shouldStretchPage: boolean; + shouldOffsetDoubleSpreads: boolean; + readingDirection: ReadingDirection; + readingMode: ReadingMode; + /** + * pixel + */ + pageGap: number; + readerWidth: { + /** + * percentage + */ + value: number; + enabled: boolean; + }; +} + +export interface IReaderSettings extends IReaderSettingsGlobal, IReaderSettingsManga {} + +export interface IReaderSettingsWithDefaultFlag + extends IReaderSettingsGlobal, + TransformRecordToWithDefaultFlag {} + export interface ReaderStateChapters { chapters: TChapterReader[]; currentChapter?: TChapterReader | null; @@ -47,20 +154,25 @@ export interface ReaderStateChapters { setReaderStateChapters: React.Dispatch>>; } -export interface IReaderSettings { - staticNav: boolean; - showPageNumber: boolean; - loadNextOnEnding: boolean; - skipDupChapters: boolean; - readerWidth: number; - tapZoneLayout: TapZoneLayouts; - tapZoneInvertMode: TapZoneInvertMode; - progressBarType: ProgressBarType; - progressBarSize: number; - progressBarPosition: ProgressBarPosition; - pageScaleMode: ReaderPageScaleMode; - shouldScalePage: boolean; - shouldOffsetDoubleSpreads: boolean; - readingDirection: ReadingDirection; - readingMode: ReadingMode; +interface ReaderSettingsTypeBaseProps { + settings: IReaderSettingsWithDefaultFlag; + updateSetting: ( + ...args: OmitFirst> + ) => ReturnType; } + +export interface ReaderSettingsDefaultableProps { + isDefaultable: boolean; + onDefault: (setting: keyof IReaderSettings) => void; +} + +interface ReaderSettingsTypeDefaultableProps extends ReaderSettingsTypeBaseProps, ReaderSettingsDefaultableProps { + isDefaultable: true; +} + +interface ReaderSettingsTypeNonDefaultableProps extends ReaderSettingsTypeBaseProps {} + +export type ReaderSettingsTypeProps = + | ReaderSettingsTypeDefaultableProps + | (PropertiesNever> & + ReaderSettingsTypeNonDefaultableProps); diff --git a/src/modules/reader/utils/ReaderSettings.utils.tsx b/src/modules/reader/utils/ReaderSettings.utils.tsx new file mode 100644 index 00000000..cd62b46d --- /dev/null +++ b/src/modules/reader/utils/ReaderSettings.utils.tsx @@ -0,0 +1,12 @@ +/* + * 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 { IReaderSettings, ReadingMode } from '@/modules/reader/types/Reader.types.ts'; + +export const isOffsetDoubleSpreadPagesEditable = (readingMode: IReaderSettings['readingMode']): boolean => + readingMode === ReadingMode.DOUBLE_PAGE; diff --git a/src/modules/settings/Settings.types.ts b/src/modules/settings/Settings.types.ts index dabefe16..91570814 100644 --- a/src/modules/settings/Settings.types.ts +++ b/src/modules/settings/Settings.types.ts @@ -33,4 +33,4 @@ export interface ISearchSettings { ignoreFilters: boolean; } -export type ServerSettings = GetServerSettingsQuery['settings']; +export type ServerSettings = Omit;