forked from akai/readest
fd8fbb178c
Adjusting the top, bottom, left, or right page margin had no visible effect until an unrelated setting (e.g. Show Header) was toggled. The BooksGrid perf refactor (#4562) memoized the derived view/content insets on the ViewSettings object identity. saveViewSettings mutates ViewSettings in place (same reference), so the memo never recomputed on a margin edit and the new margin never reached the paginator. Left and right margins were always stale; top and bottom only refreshed when the header/footer visibility (an effect dependency) changed, which is why toggling the header appeared to apply a pending change. Extract the inset derivation into useContentInsets and memoize by the resolved numeric values instead of the object reference: identical numbers across a page turn keep a stable reference (no re-render storm), while a changed margin yields a new one that propagates to the renderer. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
import clsx from 'clsx';
|
|
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';
|
|
import { getGridTemplate, getInsetEdges } from '@/utils/grid';
|
|
import { useContentInsets } from '../hooks/useContentInsets';
|
|
import SearchResultsNav from './sidebar/SearchResultsNav';
|
|
import BooknotesNav from './sidebar/BooknotesNav';
|
|
import FoliateViewer from './FoliateViewer';
|
|
import SectionInfo from './SectionInfo';
|
|
import HeaderBar from './HeaderBar';
|
|
import PageNavigationButtons from './PageNavigationButtons';
|
|
import FooterBar from './footerbar/FooterBar';
|
|
import ProgressBar from './ProgressBar';
|
|
import Ribbon from './Ribbon';
|
|
import Annotator from './annotator/Annotator';
|
|
import FootnotePopup from './FootnotePopup';
|
|
import HintInfo from './HintInfo';
|
|
import ReadingRuler from './ReadingRuler';
|
|
import DoubleBorder from './DoubleBorder';
|
|
import ReadingStatsTracker from './ReadingStatsTracker';
|
|
|
|
interface BooksGridProps {
|
|
bookKeys: string[];
|
|
onCloseBook: (bookKey: string) => void;
|
|
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 || {};
|
|
|
|
// viewInsets/contentInsets stay stable while the user is just turning pages
|
|
// (margins are unchanged) but update when a margin setting changes — even
|
|
// though saveViewSettings mutates viewSettings in place (#4898).
|
|
const { viewInsets, contentInsets } = useContentInsets(viewSettings, gridInsets);
|
|
|
|
// 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}
|
|
/>
|
|
<ReadingStatsTracker bookKey={bookKey} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const BookCell = React.memo(BookCellInner);
|
|
|
|
const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook, onGoToLibrary }) => {
|
|
const _ = useTranslation();
|
|
const { appService } = useEnv();
|
|
// 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();
|
|
const aspectRatio = window.innerWidth / window.innerHeight;
|
|
const gridTemplate = getGridTemplate(bookKeys.length, aspectRatio);
|
|
|
|
useEffect(() => {
|
|
if (!sideBarBookKey) return;
|
|
const bookData = getBookData(sideBarBookKey);
|
|
if (!bookData || !bookData.book) return;
|
|
document.title = bookData.book.title;
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [sideBarBookKey]);
|
|
|
|
// 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 insets = perBookGridInsets[index];
|
|
if (insets) setGridInsets(bookKey, insets);
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [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={gridStyle}
|
|
role='main'
|
|
aria-label={_('Books Content')}
|
|
>
|
|
{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>
|
|
);
|
|
};
|
|
|
|
export default BooksGrid;
|