From d1e70c260969c1e1594d1aac29d70ea84dddc5e4 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Fri, 8 May 2026 01:39:48 +0200 Subject: [PATCH] Add "safe area inset" setting to reader --- CHANGELOG.md | 2 +- src/base/Base.types.ts | 34 ++++------ src/base/components/buttons/ButtonSelect.tsx | 29 ++++++--- .../buttons/ValueRotationButton.tsx | 4 +- .../components/inputs/ButtonSelectInput.tsx | 4 +- src/features/metadata/Metadata.constants.ts | 5 +- src/features/reader/Reader.types.ts | 8 +++ .../overlay/components/ReaderPageNumber.tsx | 4 +- src/features/reader/screens/Reader.tsx | 10 +-- .../settings/ReaderSettings.constants.tsx | 7 +++ .../reader/settings/ReaderSettingsMetadata.ts | 1 + .../general/ReaderGeneralSettings.tsx | 5 ++ .../components/ReaderSettingSafeAreaInset.tsx | 63 +++++++++++++++++++ .../ReaderSettingTapZoneInvertMode.tsx | 2 +- src/i18n/locales/en.po | 15 +++++ 15 files changed, 153 insertions(+), 40 deletions(-) create mode 100644 src/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e138dd8..e0abf84d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - (**Server Update**) Add an option to disable showing information when the server got updated - (**Manga**) Add an option to include client data during migration - (**Migrate**) Add bulk migration -- (**Reader**) Respect the safe inset area (e.g., top-notch) of devices +- (**Reader**) Add an option to apply safe area paddings (e.g., top-notch, rounded corners) ### Changed diff --git a/src/base/Base.types.ts b/src/base/Base.types.ts index 112eaa13..166326e2 100644 --- a/src/base/Base.types.ts +++ b/src/base/Base.types.ts @@ -20,42 +20,34 @@ export enum DirectionOffset { NEXT = 1, } -interface DisplayDataTranslation { - isTitleString?: never; - title: MessageDescriptor; +interface DisplayData { + title: MessageDescriptor | string; icon: ReactNode; } -interface DisplayDataString { - isTitleString: true; - title: string; - icon: ReactNode; -} - -type DisplayData = DisplayDataTranslation | DisplayDataString; - export type ValueToDisplayData = Record; -export interface MultiValueButtonBaseProps { +export interface MultiValueButtonBaseProps { tooltip?: string; - value: Value; + value: MultiValue; defaultValue?: Value; values: Value[]; - setValue: (value: Value) => void; + setValue: (value: MultiValue) => void; valueToDisplayData: ValueToDisplayData; } -export interface MultiValueButtonDefaultableProps extends OptionalProperty< - MultiValueButtonBaseProps, - 'value' -> { +export interface MultiValueButtonDefaultableProps< + Value extends string | number, + MultiValue extends Value | Value[] = Value, +> extends OptionalProperty, 'value'> { isDefaultable?: boolean; onDefault?: () => void; } -export type MultiValueButtonProps = - | (MultiValueButtonBaseProps & PropertiesNever>) - | MultiValueButtonDefaultableProps; +export type MultiValueButtonProps = + | (MultiValueButtonBaseProps & + PropertiesNever>) + | MultiValueButtonDefaultableProps; export enum ScrollOffset { BACKWARD, diff --git a/src/base/components/buttons/ButtonSelect.tsx b/src/base/components/buttons/ButtonSelect.tsx index f29bf9cd..ced71ccb 100644 --- a/src/base/components/buttons/ButtonSelect.tsx +++ b/src/base/components/buttons/ButtonSelect.tsx @@ -13,7 +13,7 @@ import { CustomTooltip } from '@/base/components/CustomTooltip.tsx'; import type { MultiValueButtonProps } from '@/base/Base.types.ts'; import { Superscript } from '@/base/components/texts/Superscript.tsx'; -export const ButtonSelect = ({ +export const ButtonSelect = ({ value, values, defaultValue, @@ -21,7 +21,7 @@ export const ButtonSelect = ({ valueToDisplayData, isDefaultable, onDefault, -}: MultiValueButtonProps) => { +}: MultiValueButtonProps) => { const { t } = useLingui(); return ( @@ -33,16 +33,31 @@ export const ButtonSelect = ({ )} {values.map((displayValue) => { const isDefault = value === undefined && displayValue === defaultValue; + const isMultiSelect = Array.isArray(value); + const isSelected = isMultiSelect ? value.includes(displayValue) : displayValue === value; - const text = valueToDisplayData[displayValue].isTitleString - ? valueToDisplayData[displayValue].title - : t(valueToDisplayData[displayValue].title); + const newValue = (() => { + if (value === undefined) { + return [displayValue]; + } + + if (isMultiSelect) { + return isSelected ? value.filter((v) => v !== displayValue) : [...value, displayValue]; + } + + return displayValue; + })() as MultiValue; + + const text = + typeof valueToDisplayData[displayValue].title === 'string' + ? valueToDisplayData[displayValue].title + : t(valueToDisplayData[displayValue].title); return ( diff --git a/src/base/components/inputs/ButtonSelectInput.tsx b/src/base/components/inputs/ButtonSelectInput.tsx index 429ab965..291ad1a2 100644 --- a/src/base/components/inputs/ButtonSelectInput.tsx +++ b/src/base/components/inputs/ButtonSelectInput.tsx @@ -12,11 +12,11 @@ import type { ComponentProps } from 'react'; import { ButtonSelect } from '@/base/components/buttons/ButtonSelect.tsx'; -export const ButtonSelectInput = ({ +export const ButtonSelectInput = ({ label, description, ...buttonSelectProps -}: ComponentProps> & { label: string; description?: string }) => ( +}: ComponentProps> & { label: string; description?: string }) => ( {label} {description && ( diff --git a/src/features/metadata/Metadata.constants.ts b/src/features/metadata/Metadata.constants.ts index bce57f70..2089c5fb 100644 --- a/src/features/metadata/Metadata.constants.ts +++ b/src/features/metadata/Metadata.constants.ts @@ -8,7 +8,7 @@ import deepmerge from '@mui/utils/deepmerge'; import type { AppMetadataKeys, IMetadataMigration } from '@/features/metadata/Metadata.types.ts'; -import type { IReaderSettings, ReaderCustomFilter } from '@/features/reader/Reader.types.ts'; +import type { IReaderSettings, ReaderCustomFilter, SafeAreaInset } from '@/features/reader/Reader.types.ts'; import { ProgressBarPosition, ReaderPageScaleMode, ReadingMode } from '@/features/reader/Reader.types.ts'; import { AUTO_SCROLL_SPEED, @@ -394,6 +394,9 @@ export const APP_METADATA: Record< return locale; }, }, + safeAreaInset: { + convert: convertToObject, + }, } as const; export const VALID_APP_METADATA_KEYS = Object.keys(APP_METADATA); diff --git a/src/features/reader/Reader.types.ts b/src/features/reader/Reader.types.ts index 17cba9ff..10c0dbea 100644 --- a/src/features/reader/Reader.types.ts +++ b/src/features/reader/Reader.types.ts @@ -136,6 +136,13 @@ export enum ReaderScrollAmount { LARGE = 95, } +export interface SafeAreaInset { + top: boolean; + right: boolean; + bottom: boolean; + left: boolean; +} + export interface IReaderSettingsGlobal { overlayMode: ReaderOverlayMode; exitMode: ReaderExitMode; @@ -169,6 +176,7 @@ export interface IReaderSettingsGlobal { scrollAmount: ReaderScrollAmount; shouldUseInfiniteScroll: boolean; shouldShowTransitionPage: boolean; + safeAreaInset: SafeAreaInset; } export interface IReaderSettingsManga { diff --git a/src/features/reader/overlay/components/ReaderPageNumber.tsx b/src/features/reader/overlay/components/ReaderPageNumber.tsx index be1ad9d0..f8359b6b 100644 --- a/src/features/reader/overlay/components/ReaderPageNumber.tsx +++ b/src/features/reader/overlay/components/ReaderPageNumber.tsx @@ -36,6 +36,7 @@ const BaseReaderPageNumber = ({ shouldShowPageNumber: state.shouldShowPageNumber, progressBarType: state.progressBarType, })); + const safeAreaInset = useReaderSettingsStore((state) => state.safeAreaInset); const isMaximized = useReaderProgressBarStore('isMaximized'); const pageName = useMemo(() => { @@ -68,7 +69,8 @@ const BaseReaderPageNumber = ({ position: 'fixed', left: readerNavBarWidth, right: 0, - bottom: (theme) => `calc(${theme.spacing(1)} + max(${scrollbar.xSize}px, env(safe-area-inset-bottom)))`, + bottom: (theme) => + `calc(${theme.spacing(1)} + max(${scrollbar.xSize}px, ${safeAreaInset.bottom ? 'env(safe-area-inset-bottom)' : '0px'}))`, alignItems: 'center', transition: (theme) => `left 0.${theme.transitions.duration.shortest}s`, }} diff --git a/src/features/reader/screens/Reader.tsx b/src/features/reader/screens/Reader.tsx index a010d081..bff1698d 100644 --- a/src/features/reader/screens/Reader.tsx +++ b/src/features/reader/screens/Reader.tsx @@ -74,7 +74,9 @@ const BaseReader = ({ 'tapZoneInvertMode', 'shouldShowReadingModePreview', 'shouldShowTapZoneLayoutPreview', + 'safeAreaInset', ); + const safeAreaInset = useReaderSettingsStore((state) => state.safeAreaInset); const scrollElementRef = useRef(null); @@ -228,10 +230,10 @@ const BaseReader = ({ maxWidth: `calc(100vw - ${readerNavBarWidth}px)`, width: `calc(100vw - ${readerNavBarWidth}px)`, height: `100vh`, - pt: 'env(safe-area-inset-top)', - pb: 'env(safe-area-inset-bottom)', - pr: 'env(safe-area-inset-right)', - pl: 'env(safe-area-inset-left)', + pt: safeAreaInset.top ? 'env(safe-area-inset-top)' : undefined, + pb: safeAreaInset.bottom ? 'env(safe-area-inset-bottom)' : undefined, + pr: safeAreaInset.right ? 'env(safe-area-inset-right)' : undefined, + pl: safeAreaInset.left ? 'env(safe-area-inset-left)' : undefined, marginLeft: `${readerNavBarWidth}px`, transition: (theme) => `width 0.${theme.transitions.duration.shortest}s, margin-left 0.${theme.transitions.duration.shortest}s`, diff --git a/src/features/reader/settings/ReaderSettings.constants.tsx b/src/features/reader/settings/ReaderSettings.constants.tsx index f82a3320..ed376d99 100644 --- a/src/features/reader/settings/ReaderSettings.constants.tsx +++ b/src/features/reader/settings/ReaderSettings.constants.tsx @@ -155,6 +155,7 @@ const GLOBAL_READER_SETTING_OBJECT: Record = { diff --git a/src/features/reader/settings/ReaderSettingsMetadata.ts b/src/features/reader/settings/ReaderSettingsMetadata.ts index da6ea54f..a6bb9310 100644 --- a/src/features/reader/settings/ReaderSettingsMetadata.ts +++ b/src/features/reader/settings/ReaderSettingsMetadata.ts @@ -84,6 +84,7 @@ const convertSettingsToMetadata = ( readerWidth: JSON.stringify(settings.readerWidth), hotkeys: JSON.stringify(settings.hotkeys), autoScroll: JSON.stringify(settings.autoScroll), + safeAreaInset: JSON.stringify(settings.autoScroll), }); export const DEFAULT_READER_SETTINGS_WITH_DEFAULT_FLAG = convertToSettingsWithDefaultFlag( diff --git a/src/features/reader/settings/general/ReaderGeneralSettings.tsx b/src/features/reader/settings/general/ReaderGeneralSettings.tsx index e1b76698..7e99ea23 100644 --- a/src/features/reader/settings/general/ReaderGeneralSettings.tsx +++ b/src/features/reader/settings/general/ReaderGeneralSettings.tsx @@ -16,6 +16,7 @@ import { ProgressBarType, ReaderOverlayMode } from '@/features/reader/Reader.typ import { ReaderSettingOverlayMode } from '@/features/reader/overlay/settings/ReaderSettingOverlayMode.tsx'; import { CheckboxInput } from '@/base/components/inputs/CheckboxInput.tsx'; import { ReaderSettingBackgroundColor } from '@/features/reader/settings/general/components/ReaderSettingBackgroundColor.tsx'; +import { ReaderSettingSafeAreaInset } from '@/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx'; export const ReaderGeneralSettings = ({ overlayMode, @@ -59,6 +60,10 @@ export const ReaderGeneralSettings = ({ onChange={(_, checked) => updateSetting('shouldShowPageNumber', checked)} /> )} + updateSetting('safeAreaInset', value)} + /> ); }; diff --git a/src/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx b/src/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx new file mode 100644 index 00000000..e7deafdd --- /dev/null +++ b/src/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx @@ -0,0 +1,63 @@ +/* + * 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 { useLingui } from '@lingui/react/macro'; +import { msg } from '@lingui/core/macro'; +import type { ValueToDisplayData } from '@/base/Base.types.ts'; +import { ButtonSelectInput } from '@/base/components/inputs/ButtonSelectInput.tsx'; +import type { IReaderSettingsWithDefaultFlag, SafeAreaInset } from '@/features/reader/Reader.types.ts'; +import { DEFAULT_READER_SETTINGS } from '@/features/reader/settings/ReaderSettings.constants.tsx'; + +const VALUE_TO_DISPLAY_DATA: ValueToDisplayData = { + top: { + title: msg`Top`, + icon: null, + }, + right: { + title: msg`Right`, + icon: null, + }, + bottom: { + title: msg`Bottom`, + icon: null, + }, + left: { + title: msg`Left`, + icon: null, + }, +}; + +const READER_SAFE_AREA_INSET_VALUES = Object.keys(DEFAULT_READER_SETTINGS.safeAreaInset) as (keyof SafeAreaInset)[]; + +export const ReaderSettingSafeAreaInset = ({ + safeAreaInset, + updateSetting, +}: Pick & { + updateSetting: (safeAreaInset: SafeAreaInset) => void; +}) => { + const { t } = useLingui(); + + return ( + enabled) + .map(([key]) => key as keyof SafeAreaInset)} + values={READER_SAFE_AREA_INSET_VALUES} + setValue={(values) => + updateSetting( + Object.fromEntries( + READER_SAFE_AREA_INSET_VALUES.map((key) => [key, values.includes(key)]), + ) as unknown as SafeAreaInset, + ) + } + valueToDisplayData={VALUE_TO_DISPLAY_DATA} + /> + ); +}; diff --git a/src/features/reader/settings/layout/components/ReaderSettingTapZoneInvertMode.tsx b/src/features/reader/settings/layout/components/ReaderSettingTapZoneInvertMode.tsx index 9602a3bf..4b81c119 100644 --- a/src/features/reader/settings/layout/components/ReaderSettingTapZoneInvertMode.tsx +++ b/src/features/reader/settings/layout/components/ReaderSettingTapZoneInvertMode.tsx @@ -93,7 +93,7 @@ export const ReaderSettingTapZoneInvertMode = ({ const tapZonesInvertOption = convertTapZoneInvertModeToOption(tapZoneInvertMode.value); return ( - {...buttonSelectInputProps} label={t`Invert tap zones`} value={tapZoneInvertMode.isDefault ? undefined : tapZonesInvertOption} diff --git a/src/i18n/locales/en.po b/src/i18n/locales/en.po index 8151d911..fa353cbe 100644 --- a/src/i18n/locales/en.po +++ b/src/i18n/locales/en.po @@ -414,6 +414,10 @@ msgstr "Also remove from {0}" msgid "Appearance" msgstr "Appearance" +#: src/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx +msgid "Apply safe area padding" +msgstr "Apply safe area padding" + #: src/features/migration/MigrationManager.ts msgid "Are you sure you want to abort the migration?" msgstr "Are you sure you want to abort the migration?" @@ -587,6 +591,7 @@ msgid "Both" msgstr "Both" #: src/features/reader/overlay/progress-bar/settings/components/ReaderSettingProgressBarPosition.tsx +#: src/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx msgid "Bottom" msgstr "Bottom" @@ -2052,6 +2057,7 @@ msgid "Layout" msgstr "Layout" #: src/features/reader/overlay/progress-bar/settings/components/ReaderSettingProgressBarPosition.tsx +#: src/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx msgid "Left" msgstr "Left" @@ -2623,6 +2629,10 @@ msgstr "Preload images" msgid "Press keys" msgstr "Press keys" +#: src/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx +msgid "Prevents content from overlapping device cutouts and screen edges" +msgstr "Prevents content from overlapping device cutouts and screen edges" + #: src/features/settings/Settings.constants.ts msgid "Preview" msgstr "Preview" @@ -2830,6 +2840,7 @@ msgid "Retry errored pages" msgstr "Retry errored pages" #: src/features/reader/overlay/progress-bar/settings/components/ReaderSettingProgressBarPosition.tsx +#: src/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx msgid "Right" msgstr "Right" @@ -3508,6 +3519,10 @@ msgstr "Toggle offset double spreads" msgid "Toggle stretch image" msgstr "Toggle stretch image" +#: src/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx +msgid "Top" +msgstr "Top" + #: src/features/library/components/LibraryOptionsPanel.tsx msgid "Total chapters" msgstr "Total chapters"