fix(layout): use mobile footer bar in portrait mode without regressing phone panel animation, closes #3742 (#3759)

On mobile tablets/foldables (Android or iOS) in portrait, the viewport
width can exceed the Tailwind \`sm:\` breakpoint (640px), causing the
desktop footer bar to show and hiding the brightness / font size /
color / progress panels that only exist in the mobile layout.

The previous fix in #3746 introduced a regression on phones: wrapping
MobileFooterBar's children in a \`<div>\` changed the flex layout of
the footer container, and panels no longer slid cleanly behind the
navigation bar on dismissal. That PR was reverted.

This re-implementation scopes the override narrowly:

- \`forceMobileLayout\` is only true for mobile devices in portrait
  with innerWidth >= 640. Phones (innerWidth < 640) always get
  \`false\`, so every \`!forceMobileLayout && '…'\` expression
  evaluates to the original class string — phone classNames are
  set-equal to the pre-#3746 version.
- MobileFooterBar keeps its Fragment return; no wrapper div is
  introduced anywhere in the tree, preserving the panel slide-down
  animation exactly as before.
- DesktopFooterBar, NavigationBar, and the three panels
  (ColorPanel / FontLayoutPanel / NavigationPanel) gate their
  \`sm:hidden\` / \`sm:flex\` classes on \`!forceMobileLayout\` so
  they correctly show/hide on wide portrait tablets.
- The orphaned \`.force-mobile-layout\` CSS override in globals.css
  is removed since we no longer rely on a class-based escape hatch.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-04-05 23:40:36 +08:00
committed by GitHub
parent a2d17e6a79
commit 8b10e7fb17
9 changed files with 53 additions and 39 deletions
@@ -21,9 +21,14 @@ const SCREEN_BRIGHTNESS_LIMITS = {
interface ColorPanelProps {
actionTab: string;
bottomOffset: string;
forceMobileLayout: boolean;
}
export const ColorPanel: React.FC<ColorPanelProps> = ({ actionTab, bottomOffset }) => {
export const ColorPanel: React.FC<ColorPanelProps> = ({
actionTab,
bottomOffset,
forceMobileLayout,
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
@@ -72,7 +77,8 @@ export const ColorPanel: React.FC<ColorPanelProps> = ({ actionTab, bottomOffset
};
const classes = clsx(
'footerbar-color-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
'footerbar-color-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all',
!forceMobileLayout && 'sm:hidden',
actionTab === 'color'
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useRef } from 'react';
import { FaHeadphones } from 'react-icons/fa6';
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
@@ -17,7 +18,7 @@ const DesktopFooterBar: React.FC<FooterBarChildProps> = ({
progressValid,
progressFraction,
navigationHandlers,
isMobileLayout,
forceMobileLayout,
onSpeakText,
}) => {
const _ = useTranslation();
@@ -68,11 +69,10 @@ const DesktopFooterBar: React.FC<FooterBarChildProps> = ({
return (
<div
className={
isMobileLayout
? 'hidden'
: 'hidden h-8 w-full items-center gap-x-4 overflow-x-auto px-4 sm:flex'
}
className={clsx(
'hidden h-8 w-full items-center gap-x-4 overflow-x-auto px-4',
!forceMobileLayout && 'sm:flex',
)}
style={{
bottom: isMobile ? `${gridInsets.bottom * 0.33}px` : '0px',
scrollbarWidth: 'none',
@@ -32,6 +32,7 @@ interface FontLayoutPanelProps {
actionTab: string;
bottomOffset: string;
marginIconSize: number;
forceMobileLayout: boolean;
}
export const FontLayoutPanel: React.FC<FontLayoutPanelProps> = ({
@@ -39,6 +40,7 @@ export const FontLayoutPanel: React.FC<FontLayoutPanelProps> = ({
actionTab,
bottomOffset,
marginIconSize,
forceMobileLayout,
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
@@ -91,7 +93,8 @@ export const FontLayoutPanel: React.FC<FontLayoutPanelProps> = ({
}, []);
const classes = clsx(
'footerbar-font-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
'footerbar-font-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all',
!forceMobileLayout && 'sm:hidden',
actionTab === 'font'
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
@@ -11,10 +11,10 @@ import { eventDispatcher } from '@/utils/event';
import { FooterBarProps, NavigationHandlers, FooterBarChildProps } from './types';
import { debounce } from '@/utils/debounce';
import { viewPagination } from '../../hooks/usePagination';
import { RSVPControl } from '../rsvp';
import MobileFooterBar from './MobileFooterBar';
import DesktopFooterBar from './DesktopFooterBar';
import TTSControl from '../tts/TTSControl';
import { RSVPControl } from '../rsvp';
const FooterBar: React.FC<FooterBarProps> = ({
bookKey,
@@ -194,9 +194,12 @@ const FooterBar: React.FC<FooterBarProps> = ({
const footerBarRef = useRef<HTMLDivElement>(null);
useSpatialNavigation(footerBarRef, isVisible);
const isPortrait = window.innerWidth <= window.innerHeight;
const isMobile = appService?.isMobile || window.innerWidth < 640;
const isMobileLayout = isMobile || (!!appService?.isAndroidApp && isPortrait);
// Force the mobile footer bar on mobile tablets/foldables in portrait mode
// where the viewport width exceeds the `sm:` (640px) breakpoint. Phones
// (innerWidth < 640) are intentionally excluded so their styling and panel
// slide-down animation remain exactly as before — see #3742 / #3746.
const forceMobileLayout =
!!appService?.isMobile && window.innerWidth >= 640 && window.innerWidth <= window.innerHeight;
const commonProps: FooterBarChildProps = {
bookKey,
@@ -205,7 +208,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
progressValid,
progressFraction,
navigationHandlers,
isMobileLayout,
forceMobileLayout,
onSetActionTab: handleSetActionTab,
onSpeakText: handleSpeakText,
};
@@ -216,23 +219,24 @@ const FooterBar: React.FC<FooterBarProps> = ({
const containerClasses = clsx(
'footer-bar shadow-xs bottom-0 left-0 z-10 flex w-full flex-col',
isMobileLayout ? '' : 'sm:h-[52px]',
isMobileLayout ? '' : 'sm:bg-base-100 sm:border-none',
!forceMobileLayout && 'sm:h-[52px] sm:bg-base-100 sm:border-none',
'border-base-300/50 border-t',
'transition-[opacity,transform] duration-300',
isMobileLayout ? 'fixed' : window.innerWidth < 640 ? 'fixed' : 'absolute',
forceMobileLayout || window.innerWidth < 640 ? 'fixed' : 'absolute',
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
!isMobileLayout &&
!forceMobileLayout &&
(needHorizontalScroll ? 'sm:!bottom-3 sm:!h-10 sm:justify-end' : 'sm:justify-center'),
isVisible
? 'pointer-events-auto translate-y-0 opacity-100'
: isMobileLayout
: forceMobileLayout
? 'pointer-events-none translate-y-full opacity-0'
: 'pointer-events-none translate-y-full opacity-0 sm:translate-y-0',
);
const isMobile = appService?.isMobile || window.innerWidth < 640;
return (
<>
{/* Hover trigger area */}
@@ -1,4 +1,3 @@
import clsx from 'clsx';
import React from 'react';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { FooterBarChildProps } from './types';
@@ -14,17 +13,21 @@ const MobileFooterBar: React.FC<FooterBarChildProps> = ({
progressValid,
progressFraction,
navigationHandlers,
isMobileLayout,
forceMobileLayout,
onSetActionTab,
}) => {
const isMobile = isMobileLayout || window.innerWidth < 640 || window.innerHeight < 640;
const isMobile = forceMobileLayout || window.innerWidth < 640 || window.innerHeight < 640;
const sliderHeight = useResponsiveSize(28);
const marginIconSize = useResponsiveSize(20);
const bottomOffset = isMobile ? `${gridInsets.bottom * 0.33 + 64}px` : '64px';
return (
<div className={clsx(isMobileLayout && 'force-mobile-layout')}>
<ColorPanel actionTab={actionTab} bottomOffset={bottomOffset} />
<>
<ColorPanel
actionTab={actionTab}
bottomOffset={bottomOffset}
forceMobileLayout={forceMobileLayout}
/>
<NavigationPanel
bookKey={bookKey}
actionTab={actionTab}
@@ -33,21 +36,23 @@ const MobileFooterBar: React.FC<FooterBarChildProps> = ({
navigationHandlers={navigationHandlers}
bottomOffset={bottomOffset}
sliderHeight={sliderHeight}
forceMobileLayout={forceMobileLayout}
/>
<FontLayoutPanel
bookKey={bookKey}
actionTab={actionTab}
bottomOffset={bottomOffset}
marginIconSize={marginIconSize}
forceMobileLayout={forceMobileLayout}
/>
<NavigationBar
bookKey={bookKey}
actionTab={actionTab}
gridInsets={gridInsets}
isMobileLayout={isMobileLayout}
forceMobileLayout={forceMobileLayout}
onSetActionTab={onSetActionTab!}
/>
</div>
</>
);
};
@@ -16,7 +16,7 @@ interface NavigationBarProps {
bookKey: string;
actionTab: string;
gridInsets: Insets;
isMobileLayout: boolean;
forceMobileLayout: boolean;
onSetActionTab: (tab: string) => void;
}
@@ -24,10 +24,10 @@ export const NavigationBar: React.FC<NavigationBarProps> = ({
bookKey,
actionTab,
gridInsets,
isMobileLayout,
forceMobileLayout,
onSetActionTab,
}) => {
const isMobile = window.innerWidth < 640 || window.innerHeight < 640;
const isMobile = forceMobileLayout || window.innerWidth < 640 || window.innerHeight < 640;
const _ = useTranslation();
const { appService } = useEnv();
const { getViewState } = useReaderStore();
@@ -40,7 +40,7 @@ export const NavigationBar: React.FC<NavigationBarProps> = ({
<div
className={clsx(
'bg-base-200 z-30 mt-auto flex w-full justify-between px-8 py-4',
!isMobileLayout && 'sm:hidden',
!forceMobileLayout && 'sm:hidden',
)}
style={{
paddingBottom: appService?.isAndroidApp
@@ -21,6 +21,7 @@ interface NavigationPanelProps {
viewSettings?: ViewSettings;
bottomOffset: string;
sliderHeight: number;
forceMobileLayout: boolean;
}
export const NavigationPanel: React.FC<NavigationPanelProps> = ({
@@ -32,6 +33,7 @@ export const NavigationPanel: React.FC<NavigationPanelProps> = ({
viewSettings,
bottomOffset,
sliderHeight,
forceMobileLayout,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
@@ -58,7 +60,8 @@ export const NavigationPanel: React.FC<NavigationPanelProps> = ({
);
const classes = clsx(
'footerbar-progress-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
'footerbar-progress-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all',
!forceMobileLayout && 'sm:hidden',
actionTab === 'progress'
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
@@ -27,7 +27,7 @@ export interface FooterBarChildProps {
progressValid: boolean;
gridInsets: Insets;
actionTab: string;
isMobileLayout: boolean;
forceMobileLayout: boolean;
onSetActionTab: (tab: string) => void;
onSpeakText: () => void;
}
-7
View File
@@ -441,13 +441,6 @@ foliate-fxl {
mask: linear-gradient(180deg, black 0%, rgba(0, 0, 0, 0.9) 50%, rgba(0, 0, 0, 0.5) 100%);
}
/* Force mobile footer bar layout on Android portrait (overrides sm:hidden breakpoint) */
@media (min-width: 640px) {
.force-mobile-layout .sm\:hidden {
display: flex;
}
}
.visible-focus-inset-2:focus-visible {
outline: none;
box-shadow: