forked from akai/readest
perf(reader): coalesce relocate events and memoize BookCell to stop per-swipe storm (#4562)
* perf(reader): coalesce relocate events and memoize BookCell to stop per-swipe storm
FoliateViewer
-------------
foliate fires `relocate` multiple times during a swipe burst (snap
steps + intermediate stabilize). Each one ended up in setProgress,
which writes to readerProgressStore + bookDataStore. Coalesce them to
a single commit per animation frame so only the final viewport state
is persisted.
Earlier this used requestIdleCallback, but profiling on Android showed
"Fire Idle Callback" ballooning to 2.0+ s of total time per ~28 s
session: rIC backed up under sustained pressure and dumped the whole
queue into the post-swipe pause, producing exactly the "feels sluggish
right after I let go" jank we were trying to fix. rAF runs once per
frame, gets scheduled by the browser's normal vsync loop, and doesn't
accumulate when the page is busy.
BooksGrid -> BookCell
---------------------
Previously BooksGrid subscribed to the entire progresses map and
rendered every book inline. The map changes on every page turn, so the
whole bookKeys.map(...) body re-ran for every swipe. On top of that
inset-related objects (gridInsets, contentInsets) were rebuilt every
render and threaded as fresh references into 7+ children, so even
unchanged children couldn't bail out. That accounted for ~27% of
main-thread time in the Bottom-Up profile ("Animation Frame Fired"
2.6s / 27%).
Extract BookCell as its own React.memo'd component:
- Each cell subscribes only to its own book's progress via
useBookProgress(bookKey). A page turn re-renders one BookCell, not
the grid.
- viewInsets / contentInsets are memoized off their numeric inputs so
children get stable prop references across renders.
- BookCell uses per-field selectors internally for the same reason
spelled out in store/readerProgressStore.ts header.
- Dropdown handlers are wrapped in useCallback so HeaderBar's props
object stays stable.
* fix(reader): subscribe BookCell to its own viewState so settings/ribbon toggles apply live
BookCell subscribed reactively only to useBookProgress and read
viewState/viewSettings imperatively. Settings that save with
applyStyles=false (Show Header/Footer, Double Border, Border Color) and
the bookmark ribbon toggle write no progress, so the cell didn't
re-render and the chrome it gates (SectionInfo, ProgressBar, DoubleBorder,
Ribbon) only updated on the next page turn.
Subscribe to the per-book viewStates[key] slice. This is safe now that
progress lives in its own store — viewStates[key] only bumps on
low-frequency events (settings toggles, ribbon, init, sync), never on
the per-swipe relocate path — so it does not reintroduce the commit
storm the progress-store split removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { Insets } from '@/types/misc';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookProgress } from '@/store/readerProgressStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
@@ -30,13 +32,268 @@ interface BooksGridProps {
|
||||
onGoToLibrary: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-book cell rendered inside the parent grid.
|
||||
*
|
||||
* Why this is its own component:
|
||||
* - Previously BooksGrid subscribed to the *entire* `progresses` map
|
||||
* and rendered every book inline. The map changes on every page
|
||||
* turn, so the whole `bookKeys.map(...)` body re-ran for every
|
||||
* swipe and every grandchild had to be re-reconciled.
|
||||
* - On top of that, inset-related objects (`gridInsets`,
|
||||
* `contentInsets`) were rebuilt every render and threaded into
|
||||
* 7+ children as props. React saw a fresh reference every time,
|
||||
* so even unchanged children couldn't bail out — the commit
|
||||
* traversal (the `up` / `ud` / `iv` recursion in the React
|
||||
* reconciler) ran through the entire BooksGrid subtree per turn
|
||||
* and accounted for 27% main-thread time in the Bottom-Up profile
|
||||
* ("Animation Frame Fired" 2.6 s / 27 %).
|
||||
*
|
||||
* What this fixes:
|
||||
* - Each BookCell subscribes only to its own book's progress via
|
||||
* `useBookProgress(bookKey)`. A page turn re-renders one BookCell,
|
||||
* not the entire grid.
|
||||
* - `gridInsets` and `contentInsets` are memoized off their numeric
|
||||
* inputs so children get stable prop references across renders.
|
||||
* - The dropdown handler is built via useCallback so HeaderBar's
|
||||
* props object stays stable.
|
||||
* - The component is wrapped in React.memo at export so the parent
|
||||
* can re-render (e.g. when bookKeys changes) without forcing this
|
||||
* cell to.
|
||||
*/
|
||||
interface BookCellProps {
|
||||
bookKey: string;
|
||||
index: number;
|
||||
gridInsets: Insets;
|
||||
screenInsets: Insets;
|
||||
appServiceHasRoundedWindow: boolean;
|
||||
isHoveredAnim: boolean;
|
||||
hoveredBookKey: string | null;
|
||||
isDropdownOpen: boolean;
|
||||
setDropdownOpenForBook: (bookKey: string, isOpen: boolean) => void;
|
||||
onCloseBook: (bookKey: string) => void;
|
||||
onGoToLibrary: () => void;
|
||||
}
|
||||
|
||||
const BookCellInner: React.FC<BookCellProps> = ({
|
||||
bookKey,
|
||||
index,
|
||||
gridInsets,
|
||||
screenInsets,
|
||||
appServiceHasRoundedWindow,
|
||||
isHoveredAnim,
|
||||
hoveredBookKey,
|
||||
isDropdownOpen,
|
||||
setDropdownOpenForBook,
|
||||
onCloseBook,
|
||||
onGoToLibrary,
|
||||
}) => {
|
||||
// Per-field selectors — see store/readerProgressStore.ts header for the
|
||||
// "destructure-subscribes-the-whole-store" rationale.
|
||||
const getConfig = useBookDataStore((s) => s.getConfig);
|
||||
const getBookData = useBookDataStore((s) => s.getBookData);
|
||||
|
||||
// Per-cell reactive subscriptions. This cell re-renders when THIS book's
|
||||
// progress changes (page turns) OR its view state changes. Both are
|
||||
// needed: viewState carries `viewSettings` and `ribbonVisible`, which
|
||||
// gate the chrome this cell mounts (Show Header / Show Footer, Double
|
||||
// Border, bookmark Ribbon). Those settings save with applyStyles=false
|
||||
// and the ribbon toggle writes no progress, so without a viewState
|
||||
// subscription the toggles wouldn't take effect until the next page turn.
|
||||
//
|
||||
// Subscribing to the per-book slice is safe now that progress lives in
|
||||
// its own store: `viewStates[key]` only bumps on low-frequency events
|
||||
// (settings toggles, ribbon, init, sync), never on the per-swipe
|
||||
// relocate path — so this does NOT reintroduce the commit storm the
|
||||
// progress-store split removed.
|
||||
const progress = useBookProgress(bookKey);
|
||||
const viewState = useReaderStore((s) => s.viewStates[bookKey]);
|
||||
const viewSettings = viewState?.viewSettings ?? null;
|
||||
|
||||
// config / bookData are read imperatively: their relevant fields are
|
||||
// written alongside progress (setProgress / saveConfig), so the
|
||||
// subscriptions above already drive the re-render that picks them up.
|
||||
const bookData = getBookData(bookKey);
|
||||
const config = getConfig(bookKey);
|
||||
const { book, bookDoc } = bookData || {};
|
||||
|
||||
// viewSettings drives both viewInsets and the inset-derived geometry.
|
||||
// Memoize off its identity so contentInsets stays stable while the
|
||||
// user is just turning pages — viewSettings only changes when the
|
||||
// user toggles settings, not on every relocate. Same logic for
|
||||
// gridInsets which is keyed off the resolved Insets numbers.
|
||||
const viewInsets = useMemo(
|
||||
() => (viewSettings ? getViewInsets(viewSettings) : { top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
[viewSettings],
|
||||
);
|
||||
const contentInsets = useMemo(
|
||||
() => ({
|
||||
top: gridInsets.top + viewInsets.top,
|
||||
right: gridInsets.right + viewInsets.right,
|
||||
bottom: gridInsets.bottom + viewInsets.bottom,
|
||||
left: gridInsets.left + viewInsets.left,
|
||||
}),
|
||||
[
|
||||
gridInsets.top,
|
||||
gridInsets.right,
|
||||
gridInsets.bottom,
|
||||
gridInsets.left,
|
||||
viewInsets.top,
|
||||
viewInsets.right,
|
||||
viewInsets.bottom,
|
||||
viewInsets.left,
|
||||
],
|
||||
);
|
||||
|
||||
// Stable callback so HeaderBar doesn't see a new prop reference per
|
||||
// BooksGrid render.
|
||||
const onDropdownOpenChange = useCallback(
|
||||
(isOpen: boolean) => setDropdownOpenForBook(bookKey, isOpen),
|
||||
[bookKey, setDropdownOpenForBook],
|
||||
);
|
||||
|
||||
if (!book || !config || !bookDoc || !viewSettings || !viewState) return null;
|
||||
|
||||
const { section, pageinfo, sectionLabel } = progress || {};
|
||||
const isBookmarked = viewState.ribbonVisible;
|
||||
const viewerKey = viewState.viewerKey;
|
||||
const horizontalGapPercent = viewSettings.gapPercent;
|
||||
const showHeader = viewSettings.showHeader;
|
||||
const showFooter = viewSettings.showFooter;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`gridcell-${bookKey}`}
|
||||
className={clsx(
|
||||
'relative h-full w-full overflow-hidden',
|
||||
appServiceHasRoundedWindow && 'rounded-window',
|
||||
)}
|
||||
>
|
||||
{isBookmarked && !hoveredBookKey && <Ribbon width={`${horizontalGapPercent}%`} />}
|
||||
<HeaderBar
|
||||
bookKey={bookKey}
|
||||
gridInsets={gridInsets}
|
||||
screenInsets={screenInsets}
|
||||
bookTitle={book.title}
|
||||
isTopLeft={index === 0}
|
||||
isHoveredAnim={isHoveredAnim}
|
||||
onCloseBook={onCloseBook}
|
||||
onGoToLibrary={onGoToLibrary}
|
||||
onDropdownOpenChange={onDropdownOpenChange}
|
||||
/>
|
||||
<FoliateViewer
|
||||
key={viewerKey}
|
||||
bookKey={bookKey}
|
||||
bookDoc={bookDoc}
|
||||
config={config}
|
||||
gridInsets={gridInsets}
|
||||
contentInsets={contentInsets}
|
||||
/>
|
||||
{viewSettings.vertical && viewSettings.scrolled && (
|
||||
<>
|
||||
{(showFooter || viewSettings.doubleBorder) && (
|
||||
<div
|
||||
className='bg-base-100 absolute left-0 top-0 h-full'
|
||||
style={{
|
||||
width: `calc(${contentInsets.left + (viewSettings.doubleBorder ? 32 : 0)}px)`,
|
||||
height: `calc(100%)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(showHeader || viewSettings.doubleBorder) && (
|
||||
<div
|
||||
className='bg-base-100 absolute right-0 top-0 h-full'
|
||||
style={{
|
||||
width: `calc(${contentInsets.right + (viewSettings.doubleBorder ? 32 : 0)}px)`,
|
||||
height: `calc(100%)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{viewSettings.vertical && viewSettings.doubleBorder && (
|
||||
<DoubleBorder
|
||||
showHeader={showHeader}
|
||||
showFooter={showFooter}
|
||||
borderColor={viewSettings.borderColor}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
insets={viewInsets}
|
||||
/>
|
||||
)}
|
||||
{showHeader && (
|
||||
<SectionInfo
|
||||
bookKey={bookKey}
|
||||
section={sectionLabel}
|
||||
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
|
||||
isScrolled={viewSettings.scrolled}
|
||||
isVertical={viewSettings.vertical}
|
||||
isEink={viewSettings.isEink}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
contentInsets={contentInsets}
|
||||
gridInsets={gridInsets}
|
||||
/>
|
||||
)}
|
||||
<HintInfo
|
||||
bookKey={bookKey}
|
||||
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
|
||||
isScrolled={viewSettings.scrolled}
|
||||
isVertical={viewSettings.vertical}
|
||||
isEink={viewSettings.isEink}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
contentInsets={contentInsets}
|
||||
gridInsets={gridInsets}
|
||||
/>
|
||||
{viewSettings.readingRulerEnabled && viewState?.inited && (
|
||||
<ReadingRuler
|
||||
bookKey={bookKey}
|
||||
isVertical={viewSettings.vertical}
|
||||
rtl={viewSettings.rtl}
|
||||
lines={viewSettings.readingRulerLines}
|
||||
position={viewSettings.readingRulerPosition}
|
||||
opacity={viewSettings.readingRulerOpacity}
|
||||
color={viewSettings.readingRulerColor}
|
||||
bookFormat={book.format}
|
||||
viewSettings={viewSettings}
|
||||
gridInsets={gridInsets}
|
||||
/>
|
||||
)}
|
||||
{showFooter && (
|
||||
<ProgressBar
|
||||
bookKey={bookKey}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
contentInsets={contentInsets}
|
||||
gridInsets={gridInsets}
|
||||
/>
|
||||
)}
|
||||
<PageNavigationButtons bookKey={bookKey} isDropdownOpen={isDropdownOpen} />
|
||||
<Annotator bookKey={bookKey} contentInsets={contentInsets} />
|
||||
<SearchResultsNav bookKey={bookKey} gridInsets={gridInsets} />
|
||||
<BooknotesNav bookKey={bookKey} gridInsets={gridInsets} toc={bookDoc.toc || []} />
|
||||
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
|
||||
<FooterBar
|
||||
bookKey={bookKey}
|
||||
bookFormat={book.format}
|
||||
section={section}
|
||||
pageinfo={pageinfo}
|
||||
isHoveredAnim={false}
|
||||
gridInsets={gridInsets}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BookCell = React.memo(BookCellInner);
|
||||
|
||||
const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook, onGoToLibrary }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { getConfig, getBookData } = useBookDataStore();
|
||||
const { getProgress, getViewState, getViewSettings } = useReaderStore();
|
||||
const { setGridInsets, hoveredBookKey } = useReaderStore();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
// Per-field selectors — see store/readerProgressStore.ts header. The grid
|
||||
// only re-renders on hoveredBookKey changes (header/footer toggle);
|
||||
// setGridInsets is a stable action ref.
|
||||
const hoveredBookKey = useReaderStore((s) => s.hoveredBookKey);
|
||||
const setGridInsets = useReaderStore((s) => s.setGridInsets);
|
||||
const getBookData = useBookDataStore((s) => s.getBookData);
|
||||
const sideBarBookKey = useSidebarStore((s) => s.sideBarBookKey);
|
||||
const [dropdownOpenBook, setDropdownOpenBook] = useState<string>('');
|
||||
|
||||
const { safeAreaInsets: screenInsets } = useThemeStore();
|
||||
@@ -51,186 +308,73 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook, onGoToLibr
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sideBarBookKey]);
|
||||
|
||||
const calcGridInsets = (index: number, count: number) => {
|
||||
if (!screenInsets) return { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
const { top, right, bottom, left } = getInsetEdges(index, count, aspectRatio);
|
||||
return {
|
||||
top: top ? screenInsets.top : 0,
|
||||
right: right ? screenInsets.right : 0,
|
||||
bottom: bottom ? screenInsets.bottom : 0,
|
||||
left: left ? screenInsets.left : 0,
|
||||
};
|
||||
};
|
||||
// Memoize the per-book grid insets array — its identity is the input
|
||||
// to BookCell.gridInsets, and BookCell is React.memo'd. As long as
|
||||
// bookKeys / screenInsets / aspectRatio don't change, the cells'
|
||||
// gridInsets props stay reference-equal across renders.
|
||||
const perBookGridInsets = useMemo<Insets[]>(() => {
|
||||
if (!screenInsets) return [];
|
||||
return bookKeys.map((_bookKey, index) => {
|
||||
const { top, right, bottom, left } = getInsetEdges(index, bookKeys.length, aspectRatio);
|
||||
return {
|
||||
top: top ? screenInsets.top : 0,
|
||||
right: right ? screenInsets.right : 0,
|
||||
bottom: bottom ? screenInsets.bottom : 0,
|
||||
left: left ? screenInsets.left : 0,
|
||||
};
|
||||
});
|
||||
// aspectRatio is recomputed every render but its value is window-derived
|
||||
// and won't change between resizes; including it explicitly so an
|
||||
// orientation change still busts the cache.
|
||||
}, [bookKeys, screenInsets, aspectRatio]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!screenInsets) return;
|
||||
bookKeys.forEach((bookKey, index) => {
|
||||
const gridInsets = calcGridInsets(index, bookKeys.length);
|
||||
setGridInsets(bookKey, gridInsets);
|
||||
const insets = perBookGridInsets[index];
|
||||
if (insets) setGridInsets(bookKey, insets);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookKeys, screenInsets]);
|
||||
}, [bookKeys, screenInsets, perBookGridInsets]);
|
||||
|
||||
// Stable cross-cell setter for the dropdown bookkeeping — used by the
|
||||
// memoized onDropdownOpenChange callback inside each BookCell.
|
||||
const setDropdownOpenForBook = useCallback((bookKey: string, isOpen: boolean) => {
|
||||
setDropdownOpenBook(isOpen ? bookKey : '');
|
||||
}, []);
|
||||
|
||||
if (!screenInsets) return null;
|
||||
|
||||
const gridStyle = {
|
||||
gridTemplateColumns: gridTemplate.columns,
|
||||
gridTemplateRows: gridTemplate.rows,
|
||||
};
|
||||
const isHoveredAnim = bookKeys.length > 2;
|
||||
const appServiceHasRoundedWindow = !!appService?.hasRoundedWindow;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('books-grid bg-base-100 relative grid h-full flex-grow')}
|
||||
style={{
|
||||
gridTemplateColumns: gridTemplate.columns,
|
||||
gridTemplateRows: gridTemplate.rows,
|
||||
}}
|
||||
style={gridStyle}
|
||||
role='main'
|
||||
aria-label={_('Books Content')}
|
||||
>
|
||||
{bookKeys.map((bookKey, index) => {
|
||||
const bookData = getBookData(bookKey);
|
||||
const config = getConfig(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const viewState = getViewState(bookKey);
|
||||
const gridInsets = calcGridInsets(index, bookKeys.length);
|
||||
const { book, bookDoc } = bookData || {};
|
||||
if (!book || !config || !bookDoc || !viewSettings || !viewState) return null;
|
||||
|
||||
const { section, pageinfo, sectionLabel } = progress || {};
|
||||
const isBookmarked = viewState.ribbonVisible;
|
||||
const viewerKey = viewState.viewerKey;
|
||||
const horizontalGapPercent = viewSettings.gapPercent;
|
||||
const viewInsets = getViewInsets(viewSettings);
|
||||
const contentInsets = {
|
||||
top: gridInsets.top + viewInsets.top,
|
||||
right: gridInsets.right + viewInsets.right,
|
||||
bottom: gridInsets.bottom + viewInsets.bottom,
|
||||
left: gridInsets.left + viewInsets.left,
|
||||
};
|
||||
const showHeader = viewSettings.showHeader;
|
||||
const showFooter = viewSettings.showFooter;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`gridcell-${bookKey}`}
|
||||
key={bookKey}
|
||||
className={clsx(
|
||||
'relative h-full w-full overflow-hidden',
|
||||
appService?.hasRoundedWindow && 'rounded-window',
|
||||
)}
|
||||
>
|
||||
{isBookmarked && !hoveredBookKey && <Ribbon width={`${horizontalGapPercent}%`} />}
|
||||
<HeaderBar
|
||||
bookKey={bookKey}
|
||||
gridInsets={gridInsets}
|
||||
screenInsets={screenInsets}
|
||||
bookTitle={book.title}
|
||||
isTopLeft={index === 0}
|
||||
isHoveredAnim={bookKeys.length > 2}
|
||||
onCloseBook={onCloseBook}
|
||||
onGoToLibrary={onGoToLibrary}
|
||||
onDropdownOpenChange={(isOpen) => setDropdownOpenBook(isOpen ? bookKey : '')}
|
||||
/>
|
||||
<FoliateViewer
|
||||
key={viewerKey}
|
||||
bookKey={bookKey}
|
||||
bookDoc={bookDoc}
|
||||
config={config}
|
||||
gridInsets={gridInsets}
|
||||
contentInsets={contentInsets}
|
||||
/>
|
||||
{viewSettings.vertical && viewSettings.scrolled && (
|
||||
<>
|
||||
{(showFooter || viewSettings.doubleBorder) && (
|
||||
<div
|
||||
className='bg-base-100 absolute left-0 top-0 h-full'
|
||||
style={{
|
||||
width: `calc(${contentInsets.left + (viewSettings.doubleBorder ? 32 : 0)}px)`,
|
||||
height: `calc(100%)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(showHeader || viewSettings.doubleBorder) && (
|
||||
<div
|
||||
className='bg-base-100 absolute right-0 top-0 h-full'
|
||||
style={{
|
||||
width: `calc(${contentInsets.right + (viewSettings.doubleBorder ? 32 : 0)}px)`,
|
||||
height: `calc(100%)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{viewSettings.vertical && viewSettings.doubleBorder && (
|
||||
<DoubleBorder
|
||||
showHeader={showHeader}
|
||||
showFooter={showFooter}
|
||||
borderColor={viewSettings.borderColor}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
insets={viewInsets}
|
||||
/>
|
||||
)}
|
||||
{showHeader && (
|
||||
<SectionInfo
|
||||
bookKey={bookKey}
|
||||
section={sectionLabel}
|
||||
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
|
||||
isScrolled={viewSettings.scrolled}
|
||||
isVertical={viewSettings.vertical}
|
||||
isEink={viewSettings.isEink}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
contentInsets={contentInsets}
|
||||
gridInsets={gridInsets}
|
||||
/>
|
||||
)}
|
||||
<HintInfo
|
||||
bookKey={bookKey}
|
||||
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
|
||||
isScrolled={viewSettings.scrolled}
|
||||
isVertical={viewSettings.vertical}
|
||||
isEink={viewSettings.isEink}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
contentInsets={contentInsets}
|
||||
gridInsets={gridInsets}
|
||||
/>
|
||||
{viewSettings.readingRulerEnabled && viewState?.inited && (
|
||||
<ReadingRuler
|
||||
bookKey={bookKey}
|
||||
isVertical={viewSettings.vertical}
|
||||
rtl={viewSettings.rtl}
|
||||
lines={viewSettings.readingRulerLines}
|
||||
position={viewSettings.readingRulerPosition}
|
||||
opacity={viewSettings.readingRulerOpacity}
|
||||
color={viewSettings.readingRulerColor}
|
||||
bookFormat={book.format}
|
||||
viewSettings={viewSettings}
|
||||
gridInsets={gridInsets}
|
||||
/>
|
||||
)}
|
||||
{showFooter && (
|
||||
<ProgressBar
|
||||
bookKey={bookKey}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
contentInsets={contentInsets}
|
||||
gridInsets={gridInsets}
|
||||
/>
|
||||
)}
|
||||
<PageNavigationButtons
|
||||
bookKey={bookKey}
|
||||
isDropdownOpen={dropdownOpenBook === bookKey}
|
||||
/>
|
||||
<Annotator bookKey={bookKey} contentInsets={contentInsets} />
|
||||
<SearchResultsNav bookKey={bookKey} gridInsets={gridInsets} />
|
||||
<BooknotesNav bookKey={bookKey} gridInsets={gridInsets} toc={bookDoc.toc || []} />
|
||||
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
|
||||
<FooterBar
|
||||
bookKey={bookKey}
|
||||
bookFormat={book.format}
|
||||
section={section}
|
||||
pageinfo={pageinfo}
|
||||
isHoveredAnim={false}
|
||||
gridInsets={gridInsets}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{bookKeys.map((bookKey, index) => (
|
||||
<BookCell
|
||||
key={bookKey}
|
||||
bookKey={bookKey}
|
||||
index={index}
|
||||
gridInsets={perBookGridInsets[index]!}
|
||||
screenInsets={screenInsets}
|
||||
appServiceHasRoundedWindow={appServiceHasRoundedWindow}
|
||||
isHoveredAnim={isHoveredAnim}
|
||||
hoveredBookKey={hoveredBookKey}
|
||||
isDropdownOpen={dropdownOpenBook === bookKey}
|
||||
setDropdownOpenForBook={setDropdownOpenForBook}
|
||||
onCloseBook={onCloseBook}
|
||||
onGoToLibrary={onGoToLibrary}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -96,11 +96,19 @@ const FoliateViewer: React.FC<{
|
||||
const { themeCode, isDarkMode } = useThemeStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { loadFont, loadCustomFonts, getLoadedFonts, getAvailableFonts } = useCustomFontStore();
|
||||
const { getView, setView: setFoliateView, setViewInited, setProgress } = useReaderStore();
|
||||
// Per-field selectors — see store/readerProgressStore.ts header for the
|
||||
// "destructure-subscribes-the-whole-store" rationale.
|
||||
const getView = useReaderStore((s) => s.getView);
|
||||
const setFoliateView = useReaderStore((s) => s.setView);
|
||||
const setViewInited = useReaderStore((s) => s.setViewInited);
|
||||
const setProgress = useReaderStore((s) => s.setProgress);
|
||||
const setPreviewMode = useReaderStore((s) => s.setPreviewMode);
|
||||
const { getViewState, getProgress, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { getParallels } = useParallelViewStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const getViewState = useReaderStore((s) => s.getViewState);
|
||||
const getProgress = useReaderStore((s) => s.getProgress);
|
||||
const getViewSettings = useReaderStore((s) => s.getViewSettings);
|
||||
const setViewSettings = useReaderStore((s) => s.setViewSettings);
|
||||
const getParallels = useParallelViewStore((s) => s.getParallels);
|
||||
const getBookData = useBookDataStore((s) => s.getBookData);
|
||||
const { applyBackgroundTexture } = useBackgroundTexture();
|
||||
const { applyEinkMode } = useEinkMode();
|
||||
const { registerBrightnessListeners, overlayVisible, overlayLevel } =
|
||||
@@ -139,8 +147,37 @@ const FoliateViewer: React.FC<{
|
||||
useWebDAVSync(bookKey);
|
||||
useTextTranslation(bookKey, viewRef.current);
|
||||
|
||||
const progressRelocateHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
// Coalesce setProgress writes within a single animation frame.
|
||||
//
|
||||
// Why: foliate fires `relocate` multiple times during a swipe burst
|
||||
// (one per snap step / intermediate stabilize). Each call ends up in
|
||||
// `setProgress`, which writes to readerProgressStore + bookDataStore.
|
||||
// Even after we split progress into its own store, running the writes
|
||||
// back-to-back on the same frame is still wasted work — only the
|
||||
// last detail in the burst is what the user sees on screen.
|
||||
//
|
||||
// Earlier this used requestIdleCallback to defer the commit further,
|
||||
// but profiling on Android showed Fire Idle Callback ballooning to
|
||||
// 2.0+ seconds of total time per ~28 s session: rIC backed up under
|
||||
// sustained pressure and dumped the whole queue into the post-swipe
|
||||
// pause, producing exactly the "feels sluggish right after I let go"
|
||||
// jank we were trying to fix. rAF runs once per frame, gets scheduled
|
||||
// by the browser's normal vsync loop, and doesn't accumulate when
|
||||
// the page is busy — which is the behaviour we want here.
|
||||
const pendingRelocateRef = useRef<CustomEvent | null>(null);
|
||||
const relocateRafRef = useRef<number | null>(null);
|
||||
const cancelRelocateScheduled = useCallback(() => {
|
||||
const id = relocateRafRef.current;
|
||||
if (id == null) return;
|
||||
relocateRafRef.current = null;
|
||||
cancelAnimationFrame(id);
|
||||
}, []);
|
||||
const commitRelocate = useCallback(() => {
|
||||
relocateRafRef.current = null;
|
||||
const event = pendingRelocateRef.current;
|
||||
pendingRelocateRef.current = null;
|
||||
if (!event) return;
|
||||
const detail = event.detail;
|
||||
const atEnd = viewRef.current?.renderer.atEnd || false;
|
||||
const { current, next, total } = detail.location as PageInfo;
|
||||
const currentPage = atEnd && total > 0 ? total - 1 : current;
|
||||
@@ -155,8 +192,34 @@ const FoliateViewer: React.FC<{
|
||||
detail.time,
|
||||
detail.range,
|
||||
);
|
||||
}, [bookKey, setProgress]);
|
||||
|
||||
const progressRelocateHandler = (event: Event) => {
|
||||
// Always stash the latest detail; if another rAF is already pending
|
||||
// it'll pick this up and the intermediate states are skipped.
|
||||
pendingRelocateRef.current = event as CustomEvent;
|
||||
if (relocateRafRef.current != null) return;
|
||||
relocateRafRef.current = requestAnimationFrame(commitRelocate);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// On unmount: flush any pending commit synchronously before tearing
|
||||
// down — otherwise the last page-turn before the user closes the
|
||||
// book could be lost. Then cancel the scheduled handle to be safe
|
||||
// against double-fire.
|
||||
return () => {
|
||||
if (pendingRelocateRef.current) {
|
||||
try {
|
||||
commitRelocate();
|
||||
} catch {
|
||||
// Tearing down — last-effort save shouldn't crash the unmount
|
||||
}
|
||||
}
|
||||
cancelRelocateScheduled();
|
||||
pendingRelocateRef.current = null;
|
||||
};
|
||||
}, [cancelRelocateScheduled, commitRelocate]);
|
||||
|
||||
const getDocTransformHandler = ({ width, height }: { width: number; height: number }) => {
|
||||
return (event: Event) => {
|
||||
const { detail } = event as CustomEvent;
|
||||
|
||||
Reference in New Issue
Block a user