f8ad47a418
Teleprompter-style continuous scrolling toggled from the View menu (Shift+A), available only in scrolled mode. A PacedScroller drives whole-pixel forward steps at a constant, user-adjustable velocity; the speed (25-500 percent, persisted as autoScrollSpeed) is tuned from a floating control pill that also offers pause/resume and exit, and fades away while scrolling to keep the mode immersive. Tapping the page pauses and resumes instead of turning pages or toggling the bars; manual wheel or drag input simply composes with the paced scrolling. Escape or leaving scrolled mode ends the session. When forward progress stalls the session hops to the next section (single-section scroll mode) or stops with a toast at the end of the book. Vertical-writing books scroll along the horizontal axis with the sign convention foliate uses for scrolled offsets. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
481 lines
17 KiB
TypeScript
481 lines
17 KiB
TypeScript
import clsx from 'clsx';
|
|
import React, { useEffect } from 'react';
|
|
import { useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { BiMoon, BiSun } from 'react-icons/bi';
|
|
import { TbSunMoon } from 'react-icons/tb';
|
|
import { MdZoomOut, MdZoomIn, MdCheck, MdInfoOutline } from 'react-icons/md';
|
|
import { MdRemove, MdAdd, MdContrast } from 'react-icons/md';
|
|
import { MdSync, MdSyncProblem } from 'react-icons/md';
|
|
import { IoMdExpand } from 'react-icons/io';
|
|
import { IoShareOutline } from 'react-icons/io5';
|
|
import { TbArrowAutofitWidth } from 'react-icons/tb';
|
|
import { TbColumns1, TbColumns2 } from 'react-icons/tb';
|
|
|
|
import {
|
|
MAX_ZOOM_LEVEL,
|
|
MIN_ZOOM_LEVEL,
|
|
ZOOM_STEP,
|
|
MAX_CONTRAST,
|
|
MIN_CONTRAST,
|
|
CONTRAST_STEP,
|
|
} from '@/services/constants';
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { useThemeStore } from '@/store/themeStore';
|
|
import { useReaderStore } from '@/store/readerStore';
|
|
import { useBookDataStore } from '@/store/bookDataStore';
|
|
import { useSettingsStore } from '@/store/settingsStore';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { getStyles } from '@/utils/style';
|
|
import { navigateToLogin } from '@/utils/nav';
|
|
import { getScrollGapAttr } from '@/utils/webtoon';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
import { getMaxInlineSize } from '@/utils/config';
|
|
import dayjs from 'dayjs';
|
|
import { saveViewSettings } from '@/helpers/settings';
|
|
import { tauriHandleToggleFullScreen } from '@/utils/window';
|
|
import MenuItem from '@/components/MenuItem';
|
|
import Menu from '@/components/Menu';
|
|
|
|
interface ViewMenuProps {
|
|
bookKey: string;
|
|
setIsDropdownOpen?: (open: boolean) => void;
|
|
onShowMetaHashDialog?: () => void;
|
|
}
|
|
|
|
const ViewMenu: React.FC<ViewMenuProps> = ({
|
|
bookKey,
|
|
setIsDropdownOpen,
|
|
onShowMetaHashDialog,
|
|
}) => {
|
|
const _ = useTranslation();
|
|
const router = useRouter();
|
|
const { user } = useAuth();
|
|
const { envConfig, appService } = useEnv();
|
|
const { getConfig, getBookData } = useBookDataStore();
|
|
const { setSettingsDialogOpen, setSettingsDialogBookKey } = useSettingsStore();
|
|
const { getView, getViewSettings, getViewState, getProgress, setViewSettings } = useReaderStore();
|
|
const config = getConfig(bookKey)!;
|
|
const bookData = getBookData(bookKey)!;
|
|
const viewSettings = getViewSettings(bookKey)!;
|
|
const viewState = getViewState(bookKey);
|
|
|
|
const { themeMode, isDarkMode, setThemeMode } = useThemeStore();
|
|
const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled);
|
|
const [webtoonMode, setWebtoonMode] = useState(viewSettings!.webtoonMode ?? false);
|
|
const [isParagraphMode, setParagraphMode] = useState(
|
|
viewSettings?.paragraphMode?.enabled ?? false,
|
|
);
|
|
const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!);
|
|
const [contrast, setContrast] = useState(viewSettings!.contrast ?? 100);
|
|
const [zoomMode, setZoomMode] = useState(viewSettings!.zoomMode!);
|
|
const [spreadMode, setSpreadMode] = useState(viewSettings!.spreadMode!);
|
|
const [keepCoverSpread, setKeepCoverSpread] = useState(viewSettings!.keepCoverSpread!);
|
|
const [invertImgColorInDark, setInvertImgColorInDark] = useState(
|
|
viewSettings!.invertImgColorInDark,
|
|
);
|
|
const [applyThemeToPDF, setApplyThemeToPDF] = useState(viewSettings!.applyThemeToPDF!);
|
|
|
|
const zoomIn = () => setZoomLevel((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM_LEVEL));
|
|
const zoomOut = () => setZoomLevel((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM_LEVEL));
|
|
const resetZoom = () => setZoomLevel(100);
|
|
const increaseContrast = () =>
|
|
setContrast((prev) => Math.min(prev + CONTRAST_STEP, MAX_CONTRAST));
|
|
const decreaseContrast = () =>
|
|
setContrast((prev) => Math.max(prev - CONTRAST_STEP, MIN_CONTRAST));
|
|
const resetContrast = () => setContrast(100);
|
|
const toggleScrolledMode = () => setScrolledMode(!isScrolledMode);
|
|
const toggleWebtoonMode = () => setWebtoonMode(!webtoonMode);
|
|
const toggleParagraphMode = () => {
|
|
setParagraphMode(!isParagraphMode);
|
|
eventDispatcher.dispatch('toggle-paragraph-mode', { bookKey });
|
|
setIsDropdownOpen?.(false);
|
|
};
|
|
|
|
const openFontLayoutMenu = () => {
|
|
setIsDropdownOpen?.(false);
|
|
setSettingsDialogBookKey(bookKey);
|
|
setSettingsDialogOpen(true);
|
|
};
|
|
|
|
const cycleThemeMode = () => {
|
|
const nextMode = themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
|
|
setThemeMode(nextMode);
|
|
};
|
|
|
|
const handleFullScreen = () => {
|
|
tauriHandleToggleFullScreen();
|
|
setIsDropdownOpen?.(false);
|
|
};
|
|
|
|
const handleSync = () => {
|
|
if (!user) {
|
|
navigateToLogin(router);
|
|
setIsDropdownOpen?.(false);
|
|
} else {
|
|
eventDispatcher.dispatch('sync-book-progress', { bookKey });
|
|
}
|
|
};
|
|
|
|
const handleStartRSVP = () => {
|
|
setIsDropdownOpen?.(false);
|
|
eventDispatcher.dispatch('rsvp-start', { bookKey });
|
|
};
|
|
|
|
const toggleAutoScroll = () => {
|
|
setIsDropdownOpen?.(false);
|
|
eventDispatcher.dispatch('autoscroll-toggle', { bookKey });
|
|
};
|
|
|
|
const handleShare = () => {
|
|
setIsDropdownOpen?.(false);
|
|
if (!bookData?.book) return;
|
|
const progress = getProgress(bookKey);
|
|
eventDispatcher.dispatch('show-share-dialog', {
|
|
book: bookData.book,
|
|
cfi: progress?.location ?? null,
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isScrolledMode === viewSettings!.scrolled) return;
|
|
viewSettings!.scrolled = isScrolledMode;
|
|
if (!isScrolledMode && webtoonMode) setWebtoonMode(false);
|
|
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
|
|
getView(bookKey)?.renderer.setAttribute(
|
|
'max-inline-size',
|
|
`${getMaxInlineSize(viewSettings)}px`,
|
|
);
|
|
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!));
|
|
setViewSettings(bookKey, viewSettings!);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isScrolledMode]);
|
|
|
|
useEffect(() => {
|
|
if (webtoonMode === viewSettings.webtoonMode) return;
|
|
viewSettings.webtoonMode = webtoonMode;
|
|
getView(bookKey)?.renderer.setAttribute('scroll-gap', getScrollGapAttr(webtoonMode));
|
|
if (webtoonMode) {
|
|
// Webtoon Mode implies scrolled flow + fit-width (scale-factor 100) so pages
|
|
// fill the width without horizontal overflow/clipping. Reuse the existing
|
|
// scrolled / zoomLevel effects rather than duplicating their renderer wiring.
|
|
if (!isScrolledMode) setScrolledMode(true);
|
|
if (zoomLevel !== 100) setZoomLevel(100);
|
|
saveViewSettings(envConfig, bookKey, 'scrolled', true, false, false);
|
|
}
|
|
setViewSettings(bookKey, viewSettings);
|
|
saveViewSettings(envConfig, bookKey, 'webtoonMode', webtoonMode, false, false);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [webtoonMode]);
|
|
|
|
useEffect(() => {
|
|
if (zoomLevel === viewSettings.zoomLevel) return;
|
|
saveViewSettings(envConfig, bookKey, 'zoomLevel', zoomLevel, true, true);
|
|
if (bookData.bookDoc?.rendition?.layout === 'pre-paginated') {
|
|
getView(bookKey)?.renderer.setAttribute('scale-factor', zoomLevel);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [zoomLevel]);
|
|
|
|
useEffect(() => {
|
|
if (contrast === viewSettings.contrast) return;
|
|
saveViewSettings(envConfig, bookKey, 'contrast', contrast, true, true);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [contrast]);
|
|
|
|
useEffect(() => {
|
|
if (invertImgColorInDark === viewSettings.invertImgColorInDark) return;
|
|
saveViewSettings(envConfig, bookKey, 'invertImgColorInDark', invertImgColorInDark, true, true);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [invertImgColorInDark]);
|
|
|
|
useEffect(() => {
|
|
if (applyThemeToPDF === viewSettings.applyThemeToPDF) return;
|
|
saveViewSettings(envConfig, bookKey, 'applyThemeToPDF', applyThemeToPDF, true, true);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [applyThemeToPDF]);
|
|
|
|
useEffect(() => {
|
|
if (zoomMode === viewSettings.zoomMode) return;
|
|
viewSettings.zoomMode = zoomMode;
|
|
getView(bookKey)?.renderer.setAttribute('zoom', zoomMode);
|
|
setViewSettings(bookKey, viewSettings);
|
|
saveViewSettings(envConfig, bookKey, 'zoomMode', zoomMode, true, false);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [zoomMode]);
|
|
|
|
useEffect(() => {
|
|
if (spreadMode === viewSettings.spreadMode) return;
|
|
viewSettings.spreadMode = spreadMode;
|
|
getView(bookKey)?.renderer.setAttribute('spread', spreadMode);
|
|
setViewSettings(bookKey, viewSettings);
|
|
saveViewSettings(envConfig, bookKey, 'spreadMode', spreadMode, true, false);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [spreadMode]);
|
|
|
|
useEffect(() => {
|
|
if (keepCoverSpread === viewSettings.keepCoverSpread) return;
|
|
if (!bookData?.bookDoc?.sections?.length) return;
|
|
viewSettings.keepCoverSpread = keepCoverSpread;
|
|
const coverSide = bookData.bookDoc.dir === 'rtl' ? 'right' : 'left';
|
|
bookData.bookDoc.sections[0]!.pageSpread = keepCoverSpread ? '' : coverSide;
|
|
getView(bookKey)?.renderer.setAttribute('spread', spreadMode);
|
|
setViewSettings(bookKey, viewSettings);
|
|
saveViewSettings(envConfig, bookKey, 'keepCoverSpread', keepCoverSpread, true, false);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [keepCoverSpread]);
|
|
|
|
const lastSyncTime = Math.max(
|
|
config?.lastSyncedAtConfig || 0,
|
|
config?.lastSyncedAtNotes || 0,
|
|
config?.lastPushedAtConfig || 0,
|
|
config?.lastPushedAtNotes || 0,
|
|
);
|
|
|
|
return (
|
|
<Menu
|
|
className={clsx(
|
|
'view-menu dropdown-content dropdown-right no-triangle z-20 mt-1.5 border',
|
|
'bgcolor-base-200 shadow-2xl',
|
|
)}
|
|
style={{
|
|
maxWidth: `${window.innerWidth - 40}px`,
|
|
marginRight: window.innerWidth < 640 ? '-36px' : '0px',
|
|
}}
|
|
onCancel={() => setIsDropdownOpen?.(false)}
|
|
>
|
|
{bookData.bookDoc?.rendition?.layout === 'pre-paginated' && (
|
|
<>
|
|
<div
|
|
title={_('Zoom Level')}
|
|
className={clsx('flex items-center justify-between rounded-md')}
|
|
>
|
|
<button
|
|
title={_('Zoom Out')}
|
|
onClick={zoomOut}
|
|
className={clsx(
|
|
'hover:bg-base-300 text-base-content rounded-full p-2',
|
|
zoomLevel <= MIN_ZOOM_LEVEL && 'btn-disabled text-gray-400',
|
|
)}
|
|
>
|
|
<MdZoomOut />
|
|
</button>
|
|
<button
|
|
title={_('Reset Zoom')}
|
|
className={clsx(
|
|
'hover:bg-base-300 text-base-content h-8 min-h-8 w-[50%] rounded-md p-1 text-center',
|
|
)}
|
|
onClick={resetZoom}
|
|
>
|
|
{Math.round(zoomLevel)}%
|
|
</button>
|
|
<button
|
|
title={_('Zoom In')}
|
|
onClick={zoomIn}
|
|
className={clsx(
|
|
'hover:bg-base-300 text-base-content rounded-full p-2',
|
|
zoomLevel >= MAX_ZOOM_LEVEL && 'btn-disabled text-gray-400',
|
|
)}
|
|
>
|
|
<MdZoomIn />
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
title={_('Contrast')}
|
|
className={clsx('mt-2 flex items-center justify-between rounded-md')}
|
|
>
|
|
<button
|
|
title={_('Decrease Contrast')}
|
|
onClick={decreaseContrast}
|
|
className={clsx(
|
|
'hover:bg-base-300 text-base-content rounded-full p-2',
|
|
contrast <= MIN_CONTRAST && 'btn-disabled text-gray-400',
|
|
)}
|
|
>
|
|
<MdRemove />
|
|
</button>
|
|
<button
|
|
title={_('Reset Contrast')}
|
|
className={clsx(
|
|
'hover:bg-base-300 text-base-content flex h-8 min-h-8 w-[50%] items-center justify-center gap-1 rounded-md p-1 text-center',
|
|
)}
|
|
onClick={resetContrast}
|
|
>
|
|
<MdContrast size={16} />
|
|
{Math.round(contrast)}%
|
|
</button>
|
|
<button
|
|
title={_('Increase Contrast')}
|
|
onClick={increaseContrast}
|
|
className={clsx(
|
|
'hover:bg-base-300 text-base-content rounded-full p-2',
|
|
contrast >= MAX_CONTRAST && 'btn-disabled text-gray-400',
|
|
)}
|
|
>
|
|
<MdAdd />
|
|
</button>
|
|
</div>
|
|
|
|
<>
|
|
<div
|
|
title={_('Zoom Mode')}
|
|
className={clsx('my-2 flex items-center justify-between rounded-md')}
|
|
>
|
|
<button
|
|
title={_('Single Page')}
|
|
onClick={setSpreadMode.bind(null, 'none')}
|
|
className={clsx(
|
|
'hover:bg-base-300 text-base-content rounded-full p-2',
|
|
spreadMode === 'none' && 'bg-base-300/75',
|
|
)}
|
|
>
|
|
<TbColumns1 />
|
|
</button>
|
|
<button
|
|
title={_('Auto Spread')}
|
|
onClick={setSpreadMode.bind(null, 'auto')}
|
|
className={clsx(
|
|
'hover:bg-base-300 text-base-content rounded-full p-2',
|
|
spreadMode === 'auto' && 'bg-base-300/75',
|
|
)}
|
|
>
|
|
<TbColumns2 />
|
|
</button>
|
|
<div className='bg-base-300 mx-2 h-6 w-[1px]' />
|
|
<button
|
|
title={_('Fit Page')}
|
|
onClick={setZoomMode.bind(null, 'fit-page')}
|
|
className={clsx(
|
|
'hover:bg-base-300 text-base-content rounded-full p-2',
|
|
zoomMode === 'fit-page' && 'bg-base-300/75',
|
|
)}
|
|
>
|
|
<IoMdExpand />
|
|
</button>
|
|
<button
|
|
title={_('Fit Width')}
|
|
onClick={setZoomMode.bind(null, 'fit-width')}
|
|
className={clsx(
|
|
'hover:bg-base-300 text-base-content rounded-full p-2',
|
|
zoomMode === 'fit-width' && 'bg-base-300/75',
|
|
)}
|
|
>
|
|
<TbArrowAutofitWidth />
|
|
</button>
|
|
</div>
|
|
|
|
<MenuItem
|
|
label={_('Separate Cover Page')}
|
|
Icon={keepCoverSpread ? MdCheck : undefined}
|
|
onClick={() => setKeepCoverSpread(!keepCoverSpread)}
|
|
disabled={spreadMode === 'none'}
|
|
/>
|
|
<MenuItem label={_('Webtoon Mode')} toggled={webtoonMode} onClick={toggleWebtoonMode} />
|
|
</>
|
|
<hr aria-hidden='true' className='border-base-300 my-1' />
|
|
</>
|
|
)}
|
|
|
|
<MenuItem label={_('Font & Layout')} shortcut='Shift+F' onClick={openFontLayoutMenu} />
|
|
|
|
<MenuItem
|
|
label={_('Scrolled Mode')}
|
|
shortcut='Shift+J'
|
|
Icon={isScrolledMode ? MdCheck : undefined}
|
|
onClick={toggleScrolledMode}
|
|
/>
|
|
|
|
<MenuItem
|
|
label={_('Auto Scroll')}
|
|
shortcut='Shift+A'
|
|
Icon={viewState?.autoScrollEnabled ? MdCheck : undefined}
|
|
onClick={toggleAutoScroll}
|
|
disabled={!isScrolledMode}
|
|
/>
|
|
|
|
<hr aria-hidden='true' className='border-base-300 my-1' />
|
|
|
|
<MenuItem
|
|
label={_('Paragraph Mode')}
|
|
shortcut='Shift+P'
|
|
Icon={isParagraphMode ? MdCheck : undefined}
|
|
onClick={toggleParagraphMode}
|
|
disabled={bookData.isFixedLayout}
|
|
/>
|
|
|
|
<MenuItem
|
|
label={_('Speed Reading Mode')}
|
|
shortcut='Shift+V'
|
|
onClick={handleStartRSVP}
|
|
disabled={bookData.isFixedLayout}
|
|
/>
|
|
|
|
<hr aria-hidden='true' className='border-base-300 my-1' />
|
|
|
|
<MenuItem
|
|
label={
|
|
!user
|
|
? _('Sign in to Sync')
|
|
: lastSyncTime
|
|
? _('Synced {{time}}', {
|
|
time: dayjs(lastSyncTime).fromNow(),
|
|
})
|
|
: _('Never synced')
|
|
}
|
|
Icon={user ? MdSync : MdSyncProblem}
|
|
iconClassName={user && viewState?.syncing ? 'animate-reverse-spin' : ''}
|
|
onClick={handleSync}
|
|
siblings={
|
|
<button
|
|
aria-label={_('Sync Info')}
|
|
title={_('Sync Info')}
|
|
className='hover:bg-base-300 text-base-content/70 mx-1 rounded-md px-2'
|
|
onClick={() => {
|
|
setIsDropdownOpen?.(false);
|
|
onShowMetaHashDialog?.();
|
|
}}
|
|
>
|
|
<MdInfoOutline size={16} />
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
<hr aria-hidden='true' className='border-base-300 my-1' />
|
|
|
|
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
|
|
<MenuItem
|
|
label={
|
|
themeMode === 'dark'
|
|
? _('Dark Mode')
|
|
: themeMode === 'light'
|
|
? _('Light Mode')
|
|
: _('Auto Mode')
|
|
}
|
|
Icon={themeMode === 'dark' ? BiMoon : themeMode === 'light' ? BiSun : TbSunMoon}
|
|
onClick={cycleThemeMode}
|
|
/>
|
|
{bookData.book?.format === 'PDF' && appService?.supportsCanvasContext2DFilter && (
|
|
<MenuItem
|
|
label={_('Apply Theme Colors to PDF')}
|
|
Icon={applyThemeToPDF ? MdCheck : undefined}
|
|
onClick={() => setApplyThemeToPDF(!applyThemeToPDF)}
|
|
/>
|
|
)}
|
|
<MenuItem
|
|
label={_('Invert Image In Dark Mode')}
|
|
disabled={!isDarkMode}
|
|
Icon={invertImgColorInDark ? MdCheck : undefined}
|
|
onClick={() => setInvertImgColorInDark(!invertImgColorInDark)}
|
|
/>
|
|
|
|
<hr aria-hidden='true' className='border-base-300 my-1' />
|
|
|
|
<MenuItem label={_('Share Book')} Icon={IoShareOutline} onClick={handleShare} />
|
|
</Menu>
|
|
);
|
|
};
|
|
|
|
export default ViewMenu;
|