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
This commit is contained in:
Huang Xin
2025-06-17 09:36:23 +08:00
committed by GitHub
parent 8a7d0e1b0d
commit 9477789db1
11 changed files with 176 additions and 70 deletions
+1 -1
View File
@@ -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';
@@ -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<LibraryHeaderProps> = ({
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<LibraryHeaderProps> = ({
const headerRef = useRef<HTMLDivElement>(null);
const iconSize18 = useResponsiveSize(18);
const iconSize20 = useResponsiveSize(20);
const insets = useSafeAreaInsets();
useShortcuts({
onToggleSelectMode,
@@ -102,6 +104,8 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
0,
);
if (!insets) return null;
return (
<div
ref={headerRef}
@@ -111,8 +115,8 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
)}
style={{
marginTop: appService?.hasSafeAreaInset
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: '',
? `max(${insets.top}px, ${systemUIVisible ? statusBarHeight : 0}px)`
: '0px',
}}
>
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
+8 -9
View File
@@ -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',
)}
>
<div className='fixed top-0 z-40 w-full'>
<div className='top-0 z-40 w-full'>
<LibraryHeader
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
@@ -603,14 +603,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
ref={containerRef}
className={clsx(
'scroll-container drop-zone flex-grow overflow-y-auto',
appService?.hasSafeAreaInset && 'pt-[52px]',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom))]',
isDragging && 'drag-over',
)}
style={{
marginTop: appService?.hasSafeAreaInset
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: '48px',
paddingTop: '0px',
paddingRight: `${insets.right}px`,
paddingBottom: `${insets.bottom}px`,
paddingLeft: `${insets.left}px`,
}}
>
<DropIndicator />
@@ -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<BooksGridProps> = ({ 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<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sideBarBookKey]);
if (!insets) return null;
return (
<div
className={clsx('relative grid h-full flex-grow')}
@@ -50,6 +56,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ 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<BooksGridProps> = ({ 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<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
onCloseBook={onCloseBook}
onSetSettingsDialogOpen={setFontLayoutSettingsDialogOpen}
/>
<FoliateViewer bookKey={bookKey} bookDoc={bookDoc} config={config} />
<FoliateViewer
bookKey={bookKey}
bookDoc={bookDoc}
config={config}
padding={{
top: top ? insets.top : 0,
bottom: bottom ? insets.bottom : 0,
left: left ? insets.left : 0,
right: right ? insets.right : 0,
}}
/>
{viewSettings.vertical && viewSettings.scrolled && (
<>
{(showFooter || viewSettings.doubleBorder) && (
@@ -38,18 +38,21 @@ const FoliateViewer: React.FC<{
bookKey: string;
bookDoc: BookDoc;
config: BookConfig;
}> = ({ bookKey, bookDoc, config }) => {
const { appService } = useEnv();
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<FoliateView | null>(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<FoliateView | null>(null);
const containerRef = useRef<HTMLDivElement>(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 (
<>
<div
ref={containerRef}
className='foliate-viewer h-[100%] w-[100%]'
{...mouseHandlers}
{...touchHandlers}
/>
</>
<div
ref={containerRef}
className='foliate-viewer h-[100%] w-[100%]'
{...mouseHandlers}
{...touchHandlers}
/>
);
};
@@ -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(() => {
@@ -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]);
@@ -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;
};
+21 -1
View File
@@ -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) => {
+5
View File
@@ -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 */
+16 -2
View File
@@ -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,
};
};