refactor: split FooterBar into modular components for desktop and mobile (#2181)

This commit is contained in:
Huang Xin
2025-10-08 13:47:39 +08:00
committed by GitHub
parent 173aa0fcd1
commit ad6a21a68f
10 changed files with 738 additions and 423 deletions
@@ -14,7 +14,7 @@ import SettingsDialog from '@/components/settings/SettingsDialog';
import FoliateViewer from './FoliateViewer';
import SectionInfo from './SectionInfo';
import HeaderBar from './HeaderBar';
import FooterBar from './FooterBar';
import FooterBar from './footerbar/FooterBar';
import ProgressInfoView from './ProgressInfo';
import Ribbon from './Ribbon';
import Annotator from './annotator/Annotator';
@@ -1,422 +0,0 @@
import React, { useEffect } from 'react';
import clsx from 'clsx';
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { RiArrowLeftDoubleLine, RiArrowRightDoubleLine } from 'react-icons/ri';
import { FaHeadphones } from 'react-icons/fa6';
import { IoIosList as TOCIcon } from 'react-icons/io';
import { PiNotePencil as NoteIcon } from 'react-icons/pi';
import { RxSlider as SliderIcon } from 'react-icons/rx';
import { RiFontFamily as FontIcon } from 'react-icons/ri';
import { MdOutlineHeadphones as TTSIcon } from 'react-icons/md';
import { TbBoxMargin } from 'react-icons/tb';
import { RxLineHeight } from 'react-icons/rx';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { saveViewSettings } from '@/helpers/viewSettings';
import { eventDispatcher } from '@/utils/event';
import { viewPagination } from '../hooks/usePagination';
import { PageInfo } from '@/types/book';
import { Insets } from '@/types/misc';
import Button from '@/components/Button';
import Slider from '@/components/Slider';
import TTSControl from './tts/TTSControl';
interface FooterBarProps {
bookKey: string;
bookFormat: string;
section?: PageInfo;
pageinfo?: PageInfo;
isHoveredAnim: boolean;
gridInsets: Insets;
}
const FooterBar: React.FC<FooterBarProps> = ({
bookKey,
bookFormat,
section,
pageinfo,
isHoveredAnim,
gridInsets,
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { getConfig, setConfig } = useBookDataStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { getView, getViewState, getProgress, getViewSettings } = useReaderStore();
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
const [actionTab, setActionTab] = React.useState('');
const sliderHeight = useResponsiveSize(28);
const tocIconSize = useResponsiveSize(23);
const fontIconSize = useResponsiveSize(18);
const marginIconSize = useResponsiveSize(20);
const view = getView(bookKey);
const config = getConfig(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const viewState = getViewState(bookKey);
const handleProgressChange = (value: number) => {
view?.goToFraction(value / 100.0);
};
const handleFontSizeChange = (value: number) => {
saveViewSettings(envConfig, bookKey, 'defaultFontSize', value);
};
const handleMarginChange = (value: number) => {
const viewSettings = getViewSettings(bookKey)!;
const marginPx = Math.round((value / 100) * 88);
const gapPercent = Math.round((value / 100) * 10);
viewSettings.marginTopPx = marginPx;
viewSettings.marginBottomPx = marginPx / 2;
viewSettings.marginLeftPx = marginPx / 2;
viewSettings.marginRightPx = marginPx / 2;
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent, false, false);
view?.renderer.setAttribute('margin', `${marginPx}px`);
view?.renderer.setAttribute('gap', `${gapPercent}%`);
if (viewSettings?.scrolled) {
view?.renderer.setAttribute('flow', 'scrolled');
}
};
const handleLineHeightChange = (value: number) => {
saveViewSettings(envConfig, bookKey, 'lineHeight', value / 10);
};
const handleGoPrevPage = () => {
viewPagination(view, viewSettings, 'left');
};
const handleGoNextPage = () => {
viewPagination(view, viewSettings, 'right');
};
const handleGoPrevSection = () => {
if (view?.renderer.prevSection) {
view?.renderer.prevSection();
}
};
const handleGoNextSection = () => {
if (view?.renderer.nextSection) {
view?.renderer.nextSection();
}
};
const handleGoBack = () => {
view?.history.back();
};
const handleGoForward = () => {
view?.history.forward();
};
const handleSpeakText = async () => {
if (!view || !progress || !viewState) return;
if (viewState.ttsEnabled) {
eventDispatcher.dispatch('tts-stop', { bookKey });
} else {
eventDispatcher.dispatch('tts-speak', { bookKey });
}
};
const handleSetActionTab = (tab: string) => {
setActionTab(actionTab === tab ? '' : tab);
if (tab === 'tts') {
setHoveredBookKey('');
handleSpeakText();
} else if (tab === 'toc') {
setHoveredBookKey('');
if (config && config.viewSettings) {
config.viewSettings.sideBarTab = 'toc';
setConfig(bookKey, config);
}
setSideBarVisible(true);
} else if (tab === 'note') {
setHoveredBookKey('');
setSideBarVisible(true);
if (config && config.viewSettings) {
config.viewSettings.sideBarTab = 'annotations';
setConfig(bookKey, config);
}
}
};
useEffect(() => {
if (hoveredBookKey !== bookKey) {
setActionTab('');
}
}, [hoveredBookKey, bookKey]);
const getMarginProgressValue = (marginPx: number, gapPercent: number) => {
return (marginPx / 88 + gapPercent / 10) * 50;
};
const isVisible = hoveredBookKey === bookKey;
const ttsEnabled = viewState?.ttsEnabled;
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
const progressValid = !!progressInfo;
const progressFraction =
progressValid && progressInfo?.total > 0
? (progressInfo!.current + 1) / progressInfo!.total || 0
: 0;
const isMobile = window.innerWidth < 640 || window.innerHeight < 640;
return (
<>
<div
role='none'
className={clsx(
'absolute bottom-0 left-0 z-10 hidden w-full sm:flex sm:h-[52px]',
// show scroll bar when vertical and scrolled in desktop
viewSettings?.vertical && viewSettings?.scrolled && 'sm:!bottom-3 sm:!h-7',
)}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
/>
<div
role='group'
aria-label={_('Footer Bar')}
className={clsx(
'footer-bar shadow-xs bottom-0 z-10 flex w-full flex-col',
'sm:h-[52px] sm:justify-center',
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
'transition-[opacity,transform] duration-300',
// See: https://github.com/readest/readest/issues/1716
appService?.isAndroidApp && window.innerWidth < 640 ? 'fixed' : 'absolute',
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
// show scroll bar when vertical and scrolled in desktop
viewSettings?.vertical && viewSettings?.scrolled && 'sm:!bottom-3 sm:!h-7',
isVisible
? `pointer-events-auto translate-y-0 opacity-100`
: `pointer-events-none translate-y-full opacity-0 sm:translate-y-0`,
)}
dir={viewSettings?.rtl ? 'rtl' : 'ltr'}
onFocus={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onMouseLeave={() => window.innerWidth >= 640 && setHoveredBookKey('')}
>
{/* Mobile footer bar */}
<div
className={clsx(
'footerbar-progress-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all 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',
)}
style={{
bottom: isMobile ? `${gridInsets.bottom * 0.33 + 64}px` : '64px',
}}
>
<div className='flex w-full items-center justify-between gap-x-6'>
<Slider
label={_('Reading Progress')}
heightPx={sliderHeight}
bubbleLabel={`${Math.round(progressFraction * 100)}%`}
initialValue={progressValid ? progressFraction * 100 : 0}
onChange={(e) => handleProgressChange(e)}
/>
</div>
<div className='flex w-full items-center justify-between gap-x-6'>
<Button
icon={viewSettings?.rtl ? <RiArrowRightDoubleLine /> : <RiArrowLeftDoubleLine />}
onClick={viewSettings?.rtl ? handleGoNextSection : handleGoPrevSection}
label={viewSettings?.rtl ? _('Next Section') : _('Previous Section')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowRightSLine /> : <RiArrowLeftSLine />}
onClick={viewSettings?.rtl ? handleGoNextPage : handleGoPrevPage}
label={viewSettings?.rtl ? _('Next Page') : _('Previous Page')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
onClick={handleGoBack}
label={_('Go Back')}
disabled={!view?.history.canGoBack}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoBackLine /> : <RiArrowGoForwardLine />}
onClick={handleGoForward}
label={_('Go Forward')}
disabled={!view?.history.canGoForward}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftSLine /> : <RiArrowRightSLine />}
onClick={viewSettings?.rtl ? handleGoPrevPage : handleGoNextPage}
label={viewSettings?.rtl ? _('Previous Page') : _('Next Page')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftDoubleLine /> : <RiArrowRightDoubleLine />}
onClick={viewSettings?.rtl ? handleGoPrevSection : handleGoNextSection}
label={viewSettings?.rtl ? _('Previous Section') : _('Next Section')}
/>
</div>
</div>
<div
className={clsx(
'footerbar-font-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all 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',
)}
style={{
bottom: isMobile ? `${gridInsets.bottom * 0.33 + 64}px` : '64px',
}}
>
<Slider
label={_('Font Size')}
initialValue={viewSettings?.defaultFontSize ?? 16}
bubbleLabel={`${viewSettings?.defaultFontSize ?? 16}`}
minLabel='A'
maxLabel='A'
minClassName='text-xs'
maxClassName='text-base'
onChange={handleFontSizeChange}
min={8}
max={30}
/>
<div className='flex w-full items-center justify-between gap-x-6'>
<Slider
label={_('Page Margin')}
initialValue={getMarginProgressValue(
viewSettings?.marginTopPx ?? 44,
viewSettings?.gapPercent ?? 5,
)}
bubbleElement={<TbBoxMargin size={marginIconSize} />}
minLabel={_('Small')}
maxLabel={_('Large')}
step={10}
onChange={handleMarginChange}
/>
<Slider
label={_('Line Spacing')}
initialValue={(viewSettings?.lineHeight ?? 1.6) * 10}
bubbleElement={<RxLineHeight size={marginIconSize} />}
minLabel={_('Small')}
maxLabel={_('Large')}
min={8}
max={24}
onChange={handleLineHeightChange}
/>
</div>
</div>
<div
className={clsx(
'bg-base-200 z-30 mt-auto flex w-full justify-between px-8 py-4 sm:hidden',
)}
style={{
paddingBottom: isMobile ? `${gridInsets.bottom * 0.33 + 16}px` : '0px',
}}
>
<Button
label={_('Table of Contents')}
icon={<TOCIcon size={tocIconSize} className='' />}
onClick={() => handleSetActionTab('toc')}
/>
<Button
label={_('Notes')}
icon={<NoteIcon className='' />}
onClick={() => handleSetActionTab('note')}
/>
<Button
label={_('Reading Progress')}
icon={<SliderIcon className={clsx(actionTab === 'progress' && 'text-blue-500')} />}
onClick={() => handleSetActionTab('progress')}
/>
<Button
label={_('Font & Layout')}
icon={
<FontIcon
size={fontIconSize}
className={clsx(actionTab === 'font' && 'text-blue-500')}
/>
}
onClick={() => handleSetActionTab('font')}
/>
<Button
label={_('Speak')}
icon={<TTSIcon className={ttsEnabled ? 'text-blue-500' : ''} />}
onClick={() => handleSetActionTab('tts')}
/>
</div>
{/* Desktop / Pad footer bar */}
<div
className='absolute hidden h-full w-full items-center gap-x-4 px-4 sm:flex'
style={{
bottom: isMobile ? `${gridInsets.bottom * 0.33}px` : '0px',
}}
>
<Button
icon={viewSettings?.rtl ? <RiArrowRightDoubleLine /> : <RiArrowLeftDoubleLine />}
onClick={viewSettings?.rtl ? handleGoNextSection : handleGoPrevSection}
label={viewSettings?.rtl ? _('Next Section') : _('Previous Section')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowRightSLine /> : <RiArrowLeftSLine />}
onClick={viewSettings?.rtl ? handleGoNextPage : handleGoPrevPage}
label={viewSettings?.rtl ? _('Next Page') : _('Previous Page')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
onClick={handleGoBack}
label={_('Go Back')}
disabled={!view?.history.canGoBack}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoBackLine /> : <RiArrowGoForwardLine />}
onClick={handleGoForward}
label={_('Go Forward')}
disabled={!view?.history.canGoForward}
/>
{progressValid && (
<span
title={_('Reading Progress')}
aria-label={_('Reading Progress') + ': ' + `${Math.round(progressFraction * 100)}%`}
className='mx-2 text-center text-sm'
>
<span aria-hidden='true'>{`${Math.round(progressFraction * 100)}%`}</span>
</span>
)}
<input
type='range'
className='text-base-content mx-2 min-w-0 flex-1'
min={0}
max={100}
aria-label={_('Jump to Location')}
value={progressValid ? progressFraction * 100 : 0}
onChange={(e) =>
handleProgressChange(parseInt((e.target as HTMLInputElement).value, 10))
}
/>
<Button
icon={<FaHeadphones className={ttsEnabled ? 'text-blue-500' : ''} />}
onClick={handleSpeakText}
label={_('Speak')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftSLine /> : <RiArrowRightSLine />}
onClick={viewSettings?.rtl ? handleGoPrevPage : handleGoNextPage}
label={viewSettings?.rtl ? _('Previous Page') : _('Next Page')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftDoubleLine /> : <RiArrowRightDoubleLine />}
onClick={viewSettings?.rtl ? handleGoPrevSection : handleGoNextSection}
label={viewSettings?.rtl ? _('Previous Section') : _('Next Section')}
/>
</div>
</div>
<TTSControl bookKey={bookKey} gridInsets={gridInsets} />
</>
);
};
export default FooterBar;
@@ -0,0 +1,115 @@
import React from 'react';
import { FaHeadphones } from 'react-icons/fa6';
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { RiArrowLeftDoubleLine, RiArrowRightDoubleLine } from 'react-icons/ri';
import { getNavigationIcon, getNavigationLabel, getNavigationHandler } from './utils';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { FooterBarChildProps } from './types';
import Button from '@/components/Button';
const DesktopFooterBar: React.FC<FooterBarChildProps> = ({
bookKey,
gridInsets,
progressValid,
progressFraction,
navigationHandlers,
onSpeakText,
}) => {
const _ = useTranslation();
const { getView, getViewState, getViewSettings } = useReaderStore();
const view = getView(bookKey);
const viewState = getViewState(bookKey);
const viewSettings = getViewSettings(bookKey);
const isMobile = window.innerWidth < 640 || window.innerHeight < 640;
return (
<div
className='absolute hidden h-full w-full items-center gap-x-4 px-4 sm:flex'
style={{ bottom: isMobile ? `${gridInsets.bottom * 0.33}px` : '0px' }}
>
<Button
icon={getNavigationIcon(
viewSettings?.rtl,
<RiArrowLeftDoubleLine />,
<RiArrowRightDoubleLine />,
)}
onClick={getNavigationHandler(
viewSettings?.rtl,
navigationHandlers.onPrevSection,
navigationHandlers.onNextSection,
)}
label={getNavigationLabel(viewSettings?.rtl, _('Previous Section'), _('Next Section'))}
/>
<Button
icon={getNavigationIcon(viewSettings?.rtl, <RiArrowLeftSLine />, <RiArrowRightSLine />)}
onClick={getNavigationHandler(
viewSettings?.rtl,
navigationHandlers.onPrevPage,
navigationHandlers.onNextPage,
)}
label={getNavigationLabel(viewSettings?.rtl, _('Previous Page'), _('Next Page'))}
/>
<Button
icon={getNavigationIcon(viewSettings?.rtl, <RiArrowGoBackLine />, <RiArrowGoForwardLine />)}
onClick={navigationHandlers.onGoBack}
label={_('Go Back')}
disabled={!view?.history.canGoBack}
/>
<Button
icon={getNavigationIcon(viewSettings?.rtl, <RiArrowGoForwardLine />, <RiArrowGoBackLine />)}
onClick={navigationHandlers.onGoForward}
label={_('Go Forward')}
disabled={!view?.history.canGoForward}
/>
{progressValid && (
<span
title={_('Reading Progress')}
aria-label={`${_('Reading Progress')}: ${Math.round(progressFraction * 100)}%`}
className='mx-2 text-center text-sm'
>
<span aria-hidden='true'>{`${Math.round(progressFraction * 100)}%`}</span>
</span>
)}
<input
type='range'
className='text-base-content mx-2 min-w-0 flex-1'
min={0}
max={100}
aria-label={_('Jump to Location')}
value={progressValid ? progressFraction * 100 : 0}
onChange={(e) => navigationHandlers.onProgressChange(parseInt(e.target.value, 10))}
/>
<Button
icon={<FaHeadphones className={viewState?.ttsEnabled ? 'text-blue-500' : ''} />}
onClick={onSpeakText!}
label={_('Speak')}
/>
<Button
icon={getNavigationIcon(viewSettings?.rtl, <RiArrowRightSLine />, <RiArrowLeftSLine />)}
onClick={getNavigationHandler(
viewSettings?.rtl,
navigationHandlers.onNextPage,
navigationHandlers.onPrevPage,
)}
label={getNavigationLabel(viewSettings?.rtl, _('Next Page'), _('Previous Page'))}
/>
<Button
icon={getNavigationIcon(
viewSettings?.rtl,
<RiArrowRightDoubleLine />,
<RiArrowLeftDoubleLine />,
)}
onClick={getNavigationHandler(
viewSettings?.rtl,
navigationHandlers.onNextSection,
navigationHandlers.onPrevSection,
)}
label={getNavigationLabel(viewSettings?.rtl, _('Next Section'), _('Previous Section'))}
/>
</div>
);
};
export default DesktopFooterBar;
@@ -0,0 +1,140 @@
import clsx from 'clsx';
import React, { useCallback } from 'react';
import { TbBoxMargin } from 'react-icons/tb';
import { RxLineHeight } from 'react-icons/rx';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { saveViewSettings } from '@/helpers/viewSettings';
import Slider from '@/components/Slider';
const FONT_SIZE_LIMITS = {
MIN: 8,
MAX: 30,
DEFAULT: 16,
} as const;
const LINE_HEIGHT_LIMITS = {
MIN: 8,
MAX: 24,
DEFAULT: 16,
MULTIPLIER: 10,
} as const;
const MARGIN_CONSTANTS = {
MAX_MARGIN_PX: 88,
MAX_GAP_PERCENT: 10,
MARGIN_RATIO: 50,
} as const;
interface FontLayoutPanelProps {
bookKey: string;
actionTab: string;
mobileBottomOffset: string;
marginIconSize: number;
}
export const FontLayoutPanel: React.FC<FontLayoutPanelProps> = ({
bookKey,
actionTab,
mobileBottomOffset,
marginIconSize,
}) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { getView, getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const view = getView(bookKey);
const handleFontSizeChange = useCallback(
(value: number) => {
saveViewSettings(envConfig, bookKey, 'defaultFontSize', value);
},
[envConfig, bookKey],
);
const handleMarginChange = useCallback(
(value: number) => {
const currentViewSettings = getViewSettings(bookKey);
if (!currentViewSettings) return;
const { MAX_MARGIN_PX, MAX_GAP_PERCENT } = MARGIN_CONSTANTS;
const marginPx = Math.round((value / 100) * MAX_MARGIN_PX);
const gapPercent = Math.round((value / 100) * MAX_GAP_PERCENT);
currentViewSettings.marginTopPx = marginPx;
currentViewSettings.marginBottomPx = marginPx / 2;
currentViewSettings.marginLeftPx = marginPx / 2;
currentViewSettings.marginRightPx = marginPx / 2;
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent, false, false);
view?.renderer.setAttribute('margin', `${marginPx}px`);
view?.renderer.setAttribute('gap', `${gapPercent}%`);
if (currentViewSettings?.scrolled) {
view?.renderer.setAttribute('flow', 'scrolled');
}
},
[envConfig, bookKey, view, getViewSettings],
);
const handleLineHeightChange = useCallback(
(value: number) => {
saveViewSettings(envConfig, bookKey, 'lineHeight', value / LINE_HEIGHT_LIMITS.MULTIPLIER);
},
[envConfig, bookKey],
);
const getMarginProgressValue = useCallback((marginPx: number, gapPercent: number) => {
const { MAX_MARGIN_PX, MAX_GAP_PERCENT, MARGIN_RATIO } = MARGIN_CONSTANTS;
return (marginPx / MAX_MARGIN_PX + gapPercent / MAX_GAP_PERCENT) * MARGIN_RATIO;
}, []);
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',
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',
);
return (
<div className={classes} style={{ bottom: mobileBottomOffset }}>
<Slider
label={_('Font Size')}
initialValue={viewSettings?.defaultFontSize ?? FONT_SIZE_LIMITS.DEFAULT}
bubbleLabel={`${viewSettings?.defaultFontSize ?? FONT_SIZE_LIMITS.DEFAULT}`}
minLabel='A'
maxLabel='A'
minClassName='text-xs'
maxClassName='text-base'
onChange={handleFontSizeChange}
min={FONT_SIZE_LIMITS.MIN}
max={FONT_SIZE_LIMITS.MAX}
/>
<div className='flex w-full items-center justify-between gap-x-6'>
<Slider
label={_('Page Margin')}
initialValue={getMarginProgressValue(
viewSettings?.marginTopPx ?? 44,
viewSettings?.gapPercent ?? 5,
)}
bubbleElement={<TbBoxMargin size={marginIconSize} />}
minLabel={_('Small')}
maxLabel={_('Large')}
step={10}
onChange={handleMarginChange}
/>
<Slider
label={_('Line Spacing')}
initialValue={(viewSettings?.lineHeight ?? 1.6) * LINE_HEIGHT_LIMITS.MULTIPLIER}
bubbleElement={<RxLineHeight size={marginIconSize} />}
minLabel={_('Small')}
maxLabel={_('Large')}
min={LINE_HEIGHT_LIMITS.MIN}
max={LINE_HEIGHT_LIMITS.MAX}
onChange={handleLineHeightChange}
/>
</div>
</div>
);
};
@@ -0,0 +1,201 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import clsx from 'clsx';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { FooterBarProps, NavigationHandlers, FooterBarChildProps } from './types';
import { viewPagination } from '../../hooks/usePagination';
import MobileFooterBar from './MobileFooterBar';
import DesktopFooterBar from './DesktopFooterBar';
import TTSControl from '../tts/TTSControl';
const FooterBar: React.FC<FooterBarProps> = ({
bookKey,
bookFormat,
section,
pageinfo,
isHoveredAnim,
gridInsets,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { getConfig, setConfig } = useBookDataStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { getView, getViewState, getProgress, getViewSettings } = useReaderStore();
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
const [actionTab, setActionTab] = useState('');
const view = getView(bookKey);
const config = getConfig(bookKey);
const viewState = getViewState(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const isVisible = hoveredBookKey === bookKey;
const progressInfo = useMemo(
() => (bookFormat === 'PDF' ? section : pageinfo),
[bookFormat, section, pageinfo],
);
const progressValid = !!progressInfo;
const progressFraction = useMemo(() => {
if (!progressValid || !progressInfo?.total || progressInfo.total <= 0) {
return 0;
}
return (progressInfo.current + 1) / progressInfo.total;
}, [progressValid, progressInfo]);
const handleProgressChange = useCallback(
(value: number) => {
view?.goToFraction(value / 100.0);
},
[view],
);
const handleGoPrevPage = useCallback(() => {
viewPagination(view, viewSettings, 'left');
}, [view, viewSettings]);
const handleGoNextPage = useCallback(() => {
viewPagination(view, viewSettings, 'right');
}, [view, viewSettings]);
const handleGoPrevSection = useCallback(() => {
view?.renderer.prevSection?.();
}, [view]);
const handleGoNextSection = useCallback(() => {
view?.renderer.nextSection?.();
}, [view]);
const handleGoBack = useCallback(() => {
view?.history.back();
}, [view]);
const handleGoForward = useCallback(() => {
view?.history.forward();
}, [view]);
const handleSpeakText = useCallback(async () => {
if (!view || !progress || !viewState) return;
const eventType = viewState.ttsEnabled ? 'tts-stop' : 'tts-speak';
eventDispatcher.dispatch(eventType, { bookKey });
}, [view, progress, viewState, bookKey]);
const handleSetActionTab = useCallback(
(tab: string) => {
setActionTab((prevTab) => (prevTab === tab ? '' : tab));
if (tab === 'tts') {
setHoveredBookKey('');
handleSpeakText();
} else if (tab === 'toc') {
setHoveredBookKey('');
if (config?.viewSettings) {
config.viewSettings.sideBarTab = 'toc';
setConfig(bookKey, config);
}
setSideBarVisible(true);
} else if (tab === 'note') {
setHoveredBookKey('');
setSideBarVisible(true);
if (config?.viewSettings) {
config.viewSettings.sideBarTab = 'annotations';
setConfig(bookKey, config);
}
}
},
[config, bookKey, setConfig, setSideBarVisible, setHoveredBookKey, handleSpeakText],
);
useEffect(() => {
if (hoveredBookKey !== bookKey) {
setActionTab('');
}
}, [hoveredBookKey, bookKey]);
const navigationHandlers: NavigationHandlers = useMemo(
() => ({
onPrevPage: handleGoPrevPage,
onNextPage: handleGoNextPage,
onPrevSection: handleGoPrevSection,
onNextSection: handleGoNextSection,
onGoBack: handleGoBack,
onGoForward: handleGoForward,
onProgressChange: handleProgressChange,
}),
[
handleGoPrevPage,
handleGoNextPage,
handleGoPrevSection,
handleGoNextSection,
handleGoBack,
handleGoForward,
handleProgressChange,
],
);
const commonProps: FooterBarChildProps = {
bookKey,
gridInsets,
actionTab,
progressValid,
progressFraction,
navigationHandlers,
onSetActionTab: handleSetActionTab,
onSpeakText: handleSpeakText,
};
const containerClasses = clsx(
'footer-bar shadow-xs bottom-0 z-10 flex w-full flex-col',
'sm:h-[52px] sm:justify-center',
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
'transition-[opacity,transform] duration-300',
appService?.isAndroidApp && window.innerWidth < 640 ? 'fixed' : 'absolute',
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
viewSettings?.vertical && viewSettings?.scrolled && 'sm:!bottom-3 sm:!h-7',
isVisible
? 'pointer-events-auto translate-y-0 opacity-100'
: 'pointer-events-none translate-y-full opacity-0 sm:translate-y-0',
);
return (
<>
{/* Hover trigger area */}
<div
role='none'
className={clsx(
'absolute bottom-0 left-0 z-10 hidden w-full sm:flex sm:h-[52px]',
viewSettings?.vertical && viewSettings?.scrolled && 'sm:!bottom-3 sm:!h-7',
)}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
/>
{/* Main footer container */}
<div
role='group'
aria-label={_('Footer Bar')}
className={containerClasses}
dir={viewSettings?.rtl ? 'rtl' : 'ltr'}
onFocus={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onMouseLeave={() => window.innerWidth >= 640 && setHoveredBookKey('')}
>
<MobileFooterBar {...commonProps} />
<DesktopFooterBar {...commonProps} />
</div>
<TTSControl bookKey={bookKey} gridInsets={gridInsets} />
</>
);
};
export default FooterBar;
@@ -0,0 +1,49 @@
import React from 'react';
import { NavigationPanel } from './NavigationPanel';
import { FontLayoutPanel } from './FontLayoutPanel';
import { NavigationBar } from './NavigationBar';
import { FooterBarChildProps } from './types';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
const MobileFooterBar: React.FC<FooterBarChildProps> = ({
bookKey,
gridInsets,
actionTab,
progressValid,
progressFraction,
navigationHandlers,
onSetActionTab,
}) => {
const isMobile = window.innerWidth < 640 || window.innerHeight < 640;
const sliderHeight = useResponsiveSize(28);
const marginIconSize = useResponsiveSize(20);
const bottomOffset = isMobile ? `${gridInsets.bottom * 0.33 + 64}px` : '64px';
const navPadding = isMobile ? `${gridInsets.bottom * 0.33 + 16}px` : '0px';
return (
<>
<NavigationPanel
actionTab={actionTab!}
progressFraction={progressFraction}
progressValid={progressValid}
navigationHandlers={navigationHandlers}
bottomOffset={bottomOffset}
sliderHeight={sliderHeight}
/>
<FontLayoutPanel
bookKey={bookKey}
actionTab={actionTab!}
mobileBottomOffset={bottomOffset}
marginIconSize={marginIconSize}
/>
<NavigationBar
bookKey={bookKey}
actionTab={actionTab!}
navPadding={navPadding}
onSetActionTab={onSetActionTab!}
/>
</>
);
};
export default MobileFooterBar;
@@ -0,0 +1,62 @@
import clsx from 'clsx';
import React from 'react';
import { IoIosList as TOCIcon } from 'react-icons/io';
import { PiNotePencil as NoteIcon } from 'react-icons/pi';
import { RxSlider as SliderIcon } from 'react-icons/rx';
import { RiFontFamily as FontIcon } from 'react-icons/ri';
import { MdOutlineHeadphones as TTSIcon } from 'react-icons/md';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import Button from '@/components/Button';
interface NavigationBarProps {
bookKey: string;
actionTab: string;
navPadding: string;
onSetActionTab: (tab: string) => void;
}
export const NavigationBar: React.FC<NavigationBarProps> = ({
bookKey,
actionTab,
navPadding: mobileNavPadding,
onSetActionTab,
}) => {
const _ = useTranslation();
const { getViewState } = useReaderStore();
const viewState = getViewState(bookKey);
const tocIconSize = useResponsiveSize(23);
const fontIconSize = useResponsiveSize(18);
return (
<div
className={clsx('bg-base-200 z-30 mt-auto flex w-full justify-between px-8 py-4 sm:hidden')}
style={{ paddingBottom: mobileNavPadding }}
>
<Button
label={_('Table of Contents')}
icon={<TOCIcon size={tocIconSize} />}
onClick={() => onSetActionTab('toc')}
/>
<Button label={_('Notes')} icon={<NoteIcon />} onClick={() => onSetActionTab('note')} />
<Button
label={_('Reading Progress')}
icon={<SliderIcon className={clsx(actionTab === 'progress' && 'text-blue-500')} />}
onClick={() => onSetActionTab('progress')}
/>
<Button
label={_('Font & Layout')}
icon={
<FontIcon size={fontIconSize} className={clsx(actionTab === 'font' && 'text-blue-500')} />
}
onClick={() => onSetActionTab('font')}
/>
<Button
label={_('Speak')}
icon={<TTSIcon className={viewState?.ttsEnabled ? 'text-blue-500' : ''} />}
onClick={() => onSetActionTab('tts')}
/>
</div>
);
};
@@ -0,0 +1,119 @@
import React from 'react';
import clsx from 'clsx';
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { RiArrowLeftDoubleLine, RiArrowRightDoubleLine } from 'react-icons/ri';
import { getNavigationIcon, getNavigationLabel, getNavigationHandler } from './utils';
import { useTranslation } from '@/hooks/useTranslation';
import { ViewSettings } from '@/types/book';
import { NavigationHandlers } from './types';
import Button from '@/components/Button';
import Slider from '@/components/Slider';
interface NavigationPanelProps {
actionTab: string;
progressFraction: number;
progressValid: boolean;
navigationHandlers: NavigationHandlers;
viewSettings?: ViewSettings;
bottomOffset: string;
sliderHeight: number;
}
export const NavigationPanel: React.FC<NavigationPanelProps> = ({
actionTab,
progressFraction,
progressValid,
navigationHandlers,
viewSettings,
bottomOffset: mobileBottomOffset,
sliderHeight,
}) => {
const _ = useTranslation();
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',
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',
);
return (
<div className={classes} style={{ bottom: mobileBottomOffset }}>
<div className='flex w-full items-center justify-between gap-x-6'>
<Slider
label={_('Reading Progress')}
heightPx={sliderHeight}
bubbleLabel={`${Math.round(progressFraction * 100)}%`}
initialValue={progressValid ? progressFraction * 100 : 0}
onChange={navigationHandlers.onProgressChange}
/>
</div>
<div className='flex w-full items-center justify-between gap-x-6'>
<Button
icon={getNavigationIcon(
viewSettings?.rtl,
<RiArrowLeftDoubleLine />,
<RiArrowRightDoubleLine />,
)}
onClick={getNavigationHandler(
viewSettings?.rtl,
navigationHandlers.onPrevSection,
navigationHandlers.onNextSection,
)}
label={getNavigationLabel(viewSettings?.rtl, _('Previous Section'), _('Next Section'))}
/>
<Button
icon={getNavigationIcon(viewSettings?.rtl, <RiArrowLeftSLine />, <RiArrowRightSLine />)}
onClick={getNavigationHandler(
viewSettings?.rtl,
navigationHandlers.onPrevPage,
navigationHandlers.onNextPage,
)}
label={getNavigationLabel(viewSettings?.rtl, _('Previous Page'), _('Next Page'))}
/>
<Button
icon={getNavigationIcon(
viewSettings?.rtl,
<RiArrowGoBackLine />,
<RiArrowGoForwardLine />,
)}
onClick={navigationHandlers.onGoBack}
label={_('Go Back')}
disabled={!navigationHandlers.onGoBack}
/>
<Button
icon={getNavigationIcon(
viewSettings?.rtl,
<RiArrowGoForwardLine />,
<RiArrowGoBackLine />,
)}
onClick={navigationHandlers.onGoForward}
label={_('Go Forward')}
disabled={!navigationHandlers.onGoForward}
/>
<Button
icon={getNavigationIcon(viewSettings?.rtl, <RiArrowRightSLine />, <RiArrowLeftSLine />)}
onClick={getNavigationHandler(
viewSettings?.rtl,
navigationHandlers.onNextPage,
navigationHandlers.onPrevPage,
)}
label={getNavigationLabel(viewSettings?.rtl, _('Next Page'), _('Previous Page'))}
/>
<Button
icon={getNavigationIcon(
viewSettings?.rtl,
<RiArrowRightDoubleLine />,
<RiArrowLeftDoubleLine />,
)}
onClick={getNavigationHandler(
viewSettings?.rtl,
navigationHandlers.onNextSection,
navigationHandlers.onPrevSection,
)}
label={getNavigationLabel(viewSettings?.rtl, _('Next Section'), _('Previous Section'))}
/>
</div>
</div>
);
};
@@ -0,0 +1,32 @@
import { PageInfo } from '@/types/book';
import { Insets } from '@/types/misc';
export interface FooterBarProps {
bookKey: string;
bookFormat: string;
section?: PageInfo;
pageinfo?: PageInfo;
isHoveredAnim: boolean;
gridInsets: Insets;
}
export interface NavigationHandlers {
onPrevPage: () => void;
onNextPage: () => void;
onPrevSection: () => void;
onNextSection: () => void;
onGoBack: () => void;
onGoForward: () => void;
onProgressChange: (value: number) => void;
}
export interface FooterBarChildProps {
bookKey: string;
navigationHandlers: NavigationHandlers;
progressFraction: number;
progressValid: boolean;
gridInsets: Insets;
actionTab: string;
onSetActionTab: (tab: string) => void;
onSpeakText: () => void;
}
@@ -0,0 +1,19 @@
import React from 'react';
export const getNavigationIcon = (
rtl: boolean | undefined,
prevIcon: React.ReactNode,
nextIcon: React.ReactNode,
) => (rtl ? nextIcon : prevIcon);
export const getNavigationLabel = (
rtl: boolean | undefined,
prevLabel: string,
nextLabel: string,
) => (rtl ? nextLabel : prevLabel);
export const getNavigationHandler = (
rtl: boolean | undefined,
prevHandler: () => void,
nextHandler: () => void,
) => (rtl ? nextHandler : prevHandler);