From 9477789db1c9f5216ffa4519c22dda9b8bc6de66 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Tue, 17 Jun 2025 09:36:23 +0800 Subject: [PATCH] layout: apply responsive safe area insets to the reader page, closes #1067 and closes #1212 (#1408) This also fixes layout glitches when showing status bar on devices without display cutouts --- apps/readest-app/src/app/error.tsx | 2 +- .../app/library/components/LibraryHeader.tsx | 10 ++- apps/readest-app/src/app/library/page.tsx | 17 ++-- .../src/app/reader/components/BooksGrid.tsx | 29 +++++-- .../app/reader/components/FoliateViewer.tsx | 85 ++++++++++++------- .../src/app/reader/components/Reader.tsx | 6 +- .../components/settings/LayoutPanel.tsx | 17 +--- .../src/hooks/useSafeAreaInsets.ts | 35 ++++++++ apps/readest-app/src/hooks/useTheme.ts | 22 ++++- apps/readest-app/src/styles/globals.css | 5 ++ apps/readest-app/src/utils/grid.ts | 18 +++- 11 files changed, 176 insertions(+), 70 deletions(-) create mode 100644 apps/readest-app/src/hooks/useSafeAreaInsets.ts diff --git a/apps/readest-app/src/app/error.tsx b/apps/readest-app/src/app/error.tsx index bcf88e80..37886d14 100644 --- a/apps/readest-app/src/app/error.tsx +++ b/apps/readest-app/src/app/error.tsx @@ -19,7 +19,7 @@ export default function Error({ error, reset }: ErrorPageProps) { useEffect(() => { setBrowserInfo(parseWebViewVersion(appService)); posthog.captureException(error); - }, [error]); + }, [appService, error]); const handleGoHome = () => { window.location.href = '/library'; diff --git a/apps/readest-app/src/app/library/components/LibraryHeader.tsx b/apps/readest-app/src/app/library/components/LibraryHeader.tsx index 3c6da2c0..5beab6ff 100644 --- a/apps/readest-app/src/app/library/components/LibraryHeader.tsx +++ b/apps/readest-app/src/app/library/components/LibraryHeader.tsx @@ -13,6 +13,7 @@ import { useThemeStore } from '@/store/themeStore'; import { useTranslation } from '@/hooks/useTranslation'; import { useLibraryStore } from '@/store/libraryStore'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; +import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets'; import { useTrafficLightStore } from '@/store/trafficLightStore'; import { navigateToLibrary } from '@/utils/nav'; import { debounce } from '@/utils/debounce'; @@ -44,7 +45,7 @@ const LibraryHeader: React.FC = ({ const router = useRouter(); const searchParams = useSearchParams(); const { appService } = useEnv(); - const { statusBarHeight } = useThemeStore(); + const { systemUIVisible, statusBarHeight } = useThemeStore(); const { currentBookshelf } = useLibraryStore(); const { isTrafficLightVisible, @@ -58,6 +59,7 @@ const LibraryHeader: React.FC = ({ const headerRef = useRef(null); const iconSize18 = useResponsiveSize(18); const iconSize20 = useResponsiveSize(20); + const insets = useSafeAreaInsets(); useShortcuts({ onToggleSelectMode, @@ -102,6 +104,8 @@ const LibraryHeader: React.FC = ({ 0, ); + if (!insets) return null; + return (
= ({ )} style={{ marginTop: appService?.hasSafeAreaInset - ? `max(env(safe-area-inset-top), ${statusBarHeight}px)` - : '', + ? `max(${insets.top}px, ${systemUIVisible ? statusBarHeight : 0}px)` + : '0px', }} >
diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 1f250aad..9601542f 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -28,7 +28,7 @@ import { usePullToRefresh } from '@/hooks/usePullToRefresh'; import { useTheme } from '@/hooks/useTheme'; import { useDemoBooks } from './hooks/useDemoBooks'; import { useBooksSync } from './hooks/useBooksSync'; -import { useThemeStore } from '@/store/themeStore'; +import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets'; import { useScreenWakeLock } from '@/hooks/useScreenWakeLock'; import { useOpenWithBooks } from '@/hooks/useOpenWithBooks'; import { lockScreenOrientation } from '@/utils/bridge'; @@ -69,8 +69,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP } = useLibraryStore(); const _ = useTranslation(); useTheme({ systemUIVisible: true, appThemeColor: 'base-200' }); + const insets = useSafeAreaInsets(); const { settings, setSettings, saveSettings } = useSettingsStore(); - const { statusBarHeight } = useThemeStore(); const [loading, setLoading] = useState(false); const isInitiating = useRef(false); const [libraryLoaded, setLibraryLoaded] = useState(false); @@ -559,7 +559,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP setShowDetailsBook(book); }; - if (!appService) { + if (!appService || !insets) { return null; } @@ -582,7 +582,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP appService?.hasRoundedWindow && 'rounded-window', )} > -
+
diff --git a/apps/readest-app/src/app/reader/components/BooksGrid.tsx b/apps/readest-app/src/app/reader/components/BooksGrid.tsx index c1708ac3..1dc1e90c 100644 --- a/apps/readest-app/src/app/reader/components/BooksGrid.tsx +++ b/apps/readest-app/src/app/reader/components/BooksGrid.tsx @@ -4,10 +4,11 @@ import React, { useEffect } from 'react'; import { useEnv } from '@/context/EnvContext'; import { useSettingsStore } from '@/store/settingsStore'; import { useReaderStore } from '@/store/readerStore'; -import { useBookDataStore } from '@/store/bookDataStore'; import { useSidebarStore } from '@/store/sidebarStore'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets'; +import { getGridTemplate, getInsetEdges } from '@/utils/grid'; import FoliateViewer from './FoliateViewer'; -import getGridTemplate from '@/utils/grid'; import SectionInfo from './SectionInfo'; import HeaderBar from './HeaderBar'; import FooterBar from './FooterBar'; @@ -31,7 +32,10 @@ const BooksGrid: React.FC = ({ bookKeys, onCloseBook }) => { const { getProgress, getViewState, getViewSettings, hoveredBookKey } = useReaderStore(); const { isSideBarVisible, sideBarBookKey } = useSidebarStore(); const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore(); - const gridTemplate = getGridTemplate(bookKeys.length, window.innerWidth / window.innerHeight); + + const insets = useSafeAreaInsets(); + const aspectRatio = window.innerWidth / window.innerHeight; + const gridTemplate = getGridTemplate(bookKeys.length, aspectRatio); useEffect(() => { if (!sideBarBookKey) return; @@ -41,6 +45,8 @@ const BooksGrid: React.FC = ({ bookKeys, onCloseBook }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [sideBarBookKey]); + if (!insets) return null; + return (
= ({ bookKeys, onCloseBook }) => { }} > {bookKeys.map((bookKey, index) => { + const { top, right, bottom, left } = getInsetEdges(index, bookKeys.length, aspectRatio); const bookData = getBookData(bookKey); const config = getConfig(bookKey); const progress = getProgress(bookKey); @@ -72,10 +79,6 @@ const BooksGrid: React.FC = ({ bookKeys, onCloseBook }) => { key={bookKey} className={clsx( 'relative h-full w-full overflow-hidden', - index === 0 && - !viewSettings?.scrolled && - appService?.hasSafeAreaInset && - 'pt-[env(safe-area-inset-top)]', !isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window', )} > @@ -88,7 +91,17 @@ const BooksGrid: React.FC = ({ bookKeys, onCloseBook }) => { onCloseBook={onCloseBook} onSetSettingsDialogOpen={setFontLayoutSettingsDialogOpen} /> - + {viewSettings.vertical && viewSettings.scrolled && ( <> {(showFooter || viewSettings.doubleBorder) && ( diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 419113d3..1a7bc65c 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -38,18 +38,21 @@ const FoliateViewer: React.FC<{ bookKey: string; bookDoc: BookDoc; config: BookConfig; -}> = ({ bookKey, bookDoc, config }) => { - const { appService } = useEnv(); - const containerRef = useRef(null); - const viewRef = useRef(null); - const isViewCreated = useRef(false); + padding: { top: number; right: number; bottom: number; left: number }; +}> = ({ bookKey, bookDoc, config, padding }) => { const { getView, setView: setFoliateView, setProgress } = useReaderStore(); const { getViewSettings, setViewSettings } = useReaderStore(); const { getParallels } = useParallelViewStore(); const { getBookData } = useBookDataStore(); + const { appService } = useEnv(); const { themeCode, isDarkMode } = useThemeStore(); + const viewSettings = getViewSettings(bookKey); + const viewRef = useRef(null); + const containerRef = useRef(null); + const isViewCreated = useRef(false); const [toastMessage, setToastMessage] = useState(''); + useEffect(() => { const timer = setTimeout(() => setToastMessage(''), 2000); return () => clearTimeout(timer); @@ -166,14 +169,6 @@ const FoliateViewer: React.FC<{ onRendererRelocate: docRelocateHandler, }); - useEffect(() => { - if (viewRef.current && viewRef.current.renderer) { - const viewSettings = getViewSettings(bookKey)!; - viewRef.current.renderer.setStyles?.(getStyles(viewSettings)); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [themeCode, isDarkMode]); - useEffect(() => { if (isViewCreated.current) return; isViewCreated.current = true; @@ -218,12 +213,6 @@ const FoliateViewer: React.FC<{ book.transformTarget?.addEventListener('data', getDocTransformHandler({ width, height })); view.renderer.setStyles?.(getStyles(viewSettings)); - const isScrolled = viewSettings.scrolled!; - const showHeader = viewSettings.showHeader!; - const showFooter = viewSettings.showFooter!; - const isCompact = !showHeader && !showFooter; - const marginPx = isCompact ? viewSettings.compactMarginPx : viewSettings.marginPx; - const gapPercent = isCompact ? viewSettings.compactGapPercent : viewSettings.gapPercent; const animated = viewSettings.animated!; const maxColumnCount = viewSettings.maxColumnCount!; const maxInlineSize = getMaxInlineSize(viewSettings); @@ -237,12 +226,10 @@ const FoliateViewer: React.FC<{ } else { view.renderer.removeAttribute('animated'); } - view.renderer.setAttribute('flow', isScrolled ? 'scrolled' : 'paginated'); - view.renderer.setAttribute('margin', `${marginPx}px`); - view.renderer.setAttribute('gap', `${gapPercent}%`); view.renderer.setAttribute('max-column-count', maxColumnCount); view.renderer.setAttribute('max-inline-size', `${maxInlineSize}px`); view.renderer.setAttribute('max-block-size', `${maxBlockSize}px`); + applyMarginAndGap(); const lastLocation = config.location; if (lastLocation) { @@ -256,15 +243,53 @@ const FoliateViewer: React.FC<{ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const applyMarginAndGap = () => { + const viewSettings = getViewSettings(bookKey)!; + const showHeader = viewSettings.showHeader!; + const showFooter = viewSettings.showFooter!; + const isCompact = !showHeader && !showFooter; + const marginPx = isCompact ? viewSettings.compactMarginPx : viewSettings.marginPx; + const gapPercent = isCompact ? viewSettings.compactGapPercent : viewSettings.gapPercent; + viewRef.current?.renderer.setAttribute('margin', `${marginPx + padding.top}px`); + viewRef.current?.renderer.setAttribute('gap', `${gapPercent}%`); + if (viewSettings.scrolled) { + viewRef.current?.renderer.setAttribute('flow', 'scrolled'); + } + }; + + useEffect(() => { + if (viewRef.current && viewRef.current.renderer) { + const viewSettings = getViewSettings(bookKey)!; + viewRef.current.renderer.setStyles?.(getStyles(viewSettings)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [themeCode, isDarkMode]); + + useEffect(() => { + if (viewRef.current && viewRef.current.renderer && viewSettings) { + applyMarginAndGap(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + padding.top, + padding.right, + padding.bottom, + padding.left, + viewSettings?.showHeader, + viewSettings?.showFooter, + viewSettings?.marginPx, + viewSettings?.compactMarginPx, + viewSettings?.gapPercent, + viewSettings?.compactGapPercent, + ]); + return ( - <> -
- +
); }; diff --git a/apps/readest-app/src/app/reader/components/Reader.tsx b/apps/readest-app/src/app/reader/components/Reader.tsx index 754f1fae..b5d1fbb6 100644 --- a/apps/readest-app/src/app/reader/components/Reader.tsx +++ b/apps/readest-app/src/app/reader/components/Reader.tsx @@ -17,7 +17,8 @@ import { useScreenWakeLock } from '@/hooks/useScreenWakeLock'; import { eventDispatcher } from '@/utils/event'; import { interceptWindowOpen } from '@/utils/open'; import { mountAdditionalFonts } from '@/utils/font'; -import { setSystemUIVisibility } from '@/utils/bridge'; +import { isTauriAppPlatform } from '@/services/environment'; +import { getSysFontsList, setSystemUIVisibility } from '@/utils/bridge'; import { AboutWindow } from '@/components/AboutWindow'; import { UpdaterWindow } from '@/components/UpdaterWindow'; import { Toast } from '@/components/Toast'; @@ -41,6 +42,9 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => { useEffect(() => { mountAdditionalFonts(document); interceptWindowOpen(); + if (isTauriAppPlatform()) { + setTimeout(getSysFontsList, 3000); + } }, []); useEffect(() => { diff --git a/apps/readest-app/src/app/reader/components/settings/LayoutPanel.tsx b/apps/readest-app/src/app/reader/components/settings/LayoutPanel.tsx index 9f953036..14f022d9 100644 --- a/apps/readest-app/src/app/reader/components/settings/LayoutPanel.tsx +++ b/apps/readest-app/src/app/reader/components/settings/LayoutPanel.tsx @@ -203,17 +203,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [showPageNumber]); - const applyMarginAndGap = () => { - const isCompact = !showHeader && !showFooter; - const marginPx = isCompact ? viewSettings.compactMarginPx : viewSettings.marginPx; - const gapPercent = isCompact ? viewSettings.compactGapPercent : viewSettings.gapPercent; - view?.renderer.setAttribute('margin', `${marginPx}px`); - view?.renderer.setAttribute('gap', `${gapPercent}%`); - if (viewSettings.scrolled) { - view?.renderer.setAttribute('flow', 'scrolled'); - } - }; - useEffect(() => { if (showHeader === viewSettings.showHeader) return; if (showHeader && !viewSettings.vertical) { @@ -229,8 +218,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => { setViewSettings(bookKey, viewSettings); } saveViewSettings(envConfig, bookKey, 'showHeader', showHeader, false, false); - - applyMarginAndGap(); + // Margin and gap settings will be applied in FoliateViewer // eslint-disable-next-line react-hooks/exhaustive-deps }, [showHeader]); @@ -249,8 +237,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => { setViewSettings(bookKey, viewSettings); } saveViewSettings(envConfig, bookKey, 'showFooter', showFooter, false, false); - - applyMarginAndGap(); + // Margin and gap settings will be applied in FoliateViewer // eslint-disable-next-line react-hooks/exhaustive-deps }, [showFooter]); diff --git a/apps/readest-app/src/hooks/useSafeAreaInsets.ts b/apps/readest-app/src/hooks/useSafeAreaInsets.ts new file mode 100644 index 00000000..143a6000 --- /dev/null +++ b/apps/readest-app/src/hooks/useSafeAreaInsets.ts @@ -0,0 +1,35 @@ +import { useState, useEffect, useCallback } from 'react'; + +export const useSafeAreaInsets = () => { + const [updated, setUpdated] = useState(false); + const [insets, setInsets] = useState({ + top: 0, + right: 0, + bottom: 0, + left: 0, + }); + + const updateSafeAreaInsets = useCallback(() => { + const rootStyles = getComputedStyle(document.documentElement); + const hasCustomProperties = rootStyles.getPropertyValue('--safe-area-inset-top'); + if (hasCustomProperties) { + setInsets({ + top: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-top')) || 0, + right: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-right')) || 0, + bottom: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-bottom')) || 0, + left: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-left')) || 0, + }); + } + setUpdated(true); + }, []); + + useEffect(() => { + updateSafeAreaInsets(); + window.addEventListener('resize', updateSafeAreaInsets); + return () => { + window.removeEventListener('resize', updateSafeAreaInsets); + }; + }, [updateSafeAreaInsets]); + + return updated ? insets : null; +}; diff --git a/apps/readest-app/src/hooks/useTheme.ts b/apps/readest-app/src/hooks/useTheme.ts index f47f1daa..bcf43368 100644 --- a/apps/readest-app/src/hooks/useTheme.ts +++ b/apps/readest-app/src/hooks/useTheme.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useEnv } from '@/context/EnvContext'; import { useThemeStore } from '@/store/themeStore'; import { useSettingsStore } from '@/store/settingsStore'; @@ -45,6 +45,26 @@ export const useTheme = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [appService, isDarkMode]); + const handleOrientationChange = useCallback(() => { + if (appService?.isIOSApp) { + if (screen.orientation.type.includes('landscape')) { + dismissSystemUI(); + setSystemUIVisibility({ visible: false, darkMode: isDarkMode }); + } else if (systemUIVisible) { + showSystemUI(); + setSystemUIVisibility({ visible: true, darkMode: isDarkMode }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appService, isDarkMode, systemUIVisible]); + + useEffect(() => { + screen.orientation.addEventListener('change', handleOrientationChange); + return () => { + screen.orientation.removeEventListener('change', handleOrientationChange); + }; + }, [handleOrientationChange]); + useEffect(() => { const customThemes = settings.globalReadSettings?.customThemes ?? []; customThemes.forEach((customTheme) => { diff --git a/apps/readest-app/src/styles/globals.css b/apps/readest-app/src/styles/globals.css index 04f316e9..1becbbcf 100644 --- a/apps/readest-app/src/styles/globals.css +++ b/apps/readest-app/src/styles/globals.css @@ -6,6 +6,11 @@ border-radius: 10px; scrollbar-gutter: auto !important; overscroll-behavior: none; + + --safe-area-inset-top: env(safe-area-inset-top); + --safe-area-inset-right: env(safe-area-inset-right); + --safe-area-inset-bottom: env(safe-area-inset-bottom); + --safe-area-inset-left: env(safe-area-inset-left); } /* Fixes the scrollbar gutter issue for DaisyUI */ diff --git a/apps/readest-app/src/utils/grid.ts b/apps/readest-app/src/utils/grid.ts index a0664685..c7f5b6c0 100644 --- a/apps/readest-app/src/utils/grid.ts +++ b/apps/readest-app/src/utils/grid.ts @@ -1,4 +1,4 @@ -const getGridTemplate = (count: number, aspectRatio: number) => { +export const getGridTemplate = (count: number, aspectRatio: number) => { if (count <= 1) { return { columns: '1fr', rows: '1fr' }; } else if (count === 2) { @@ -12,4 +12,18 @@ const getGridTemplate = (count: number, aspectRatio: number) => { } }; -export default getGridTemplate; +export const getInsetEdges = (index: number, count: number, aspectRatio: number) => { + const gridTemplate = getGridTemplate(count, aspectRatio); + const cols = gridTemplate.columns.split(' ').length; + const rows = gridTemplate.rows.split(' ').length; + + const row = Math.floor(index / cols); + const col = index % cols; + + return { + top: row === 0, + right: col === cols - 1, + bottom: row === rows - 1, + left: col === 0, + }; +};