perf: eliminate redundant re-renders of book cover components (#1685)

This commit is contained in:
Huang Xin
2025-07-27 13:02:05 +08:00
committed by GitHub
parent ffa193d116
commit 5e7f09d5c8
8 changed files with 229 additions and 179 deletions
@@ -22,7 +22,7 @@ interface BookItemProps {
mode: LibraryViewModeType;
coverFit: LibraryCoverFitType;
isSelectMode: boolean;
selectedBooks: string[];
bookSelected: boolean;
transferProgress: number | null;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
@@ -34,7 +34,7 @@ const BookItem: React.FC<BookItemProps> = ({
mode,
coverFit,
isSelectMode,
selectedBooks,
bookSelected,
transferProgress,
handleBookUpload,
handleBookDownload,
@@ -67,12 +67,12 @@ const BookItem: React.FC<BookItemProps> = ({
)}
>
<BookCover mode={mode} book={book} coverFit={coverFit} />
{selectedBooks.includes(book.hash) && (
{bookSelected && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
{isSelectMode && (
<div className='absolute bottom-1 right-1'>
{selectedBooks.includes(book.hash) ? (
{bookSelected ? (
<MdCheckCircle className='fill-blue-500' />
) : (
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
@@ -1,7 +1,7 @@
import clsx from 'clsx';
import * as React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { MdDelete, MdOpenInNew, MdOutlineCancel, MdInfoOutline } from 'react-icons/md';
import { LuFolderPlus } from 'react-icons/lu';
import { PiPlus } from 'react-icons/pi';
@@ -54,7 +54,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const { appService } = useEnv();
const { settings } = useSettingsStore();
const [loading, setLoading] = useState(false);
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [showGroupingModal, setShowGroupingModal] = useState(false);
@@ -70,6 +69,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const isImportingBook = useRef(false);
const { setCurrentBookshelf, setLibrary } = useLibraryStore();
const { setSelectedBooks, getSelectedBooks, toggleSelectedBook } = useLibraryStore();
const allBookshelfItems =
viewMode === 'grid' ? generateGridItems(libraryBooks) : generateListItems(libraryBooks);
@@ -162,24 +162,24 @@ const Bookshelf: React.FC<BookshelfProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, libraryBooks, showGroupingModal]);
const toggleSelection = (id: string) => {
setSelectedBooks((prev) =>
prev.includes(id) ? prev.filter((selectedId) => selectedId !== id) : [...prev, id],
);
};
const toggleSelection = useCallback((id: string) => {
toggleSelectedBook(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const openSelectedBooks = () => {
handleSetSelectMode(false);
if (appService?.hasWindow && settings.openBookInNewWindow) {
showReaderWindow(appService, selectedBooks);
showReaderWindow(appService, getSelectedBooks());
} else {
setTimeout(() => setLoading(true), 200);
navigateToReader(router, selectedBooks);
navigateToReader(router, getSelectedBooks());
}
};
const openBookDetails = () => {
handleSetSelectMode(false);
const selectedBooks = getSelectedBooks();
const book = libraryBooks.find((book) => book.hash === selectedBooks[0]);
if (book) {
handleShowDetailsBook(book);
@@ -188,6 +188,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const getBooksToDelete = () => {
const booksToDelete: Book[] = [];
const selectedBooks = getSelectedBooks();
selectedBooks.forEach((id) => {
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
if (book && !book.deletedAt) {
@@ -293,6 +294,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSelectMode, isSelectAll, isSelectNone]);
const selectedBooks = getSelectedBooks();
return (
<div className='bookshelf'>
<div
@@ -310,7 +313,9 @@ const Bookshelf: React.FC<BookshelfProps> = ({
mode={viewMode as LibraryViewModeType}
coverFit={coverFit as LibraryCoverFitType}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
itemSelected={
'hash' in item ? selectedBooks.includes(item.hash) : selectedBooks.includes(item.id)
}
setLoading={setLoading}
toggleSelection={toggleSelection}
handleBookUpload={handleBookUpload}
@@ -77,7 +77,7 @@ interface BookshelfItemProps {
item: BookshelfItem;
coverFit: LibraryCoverFitType;
isSelectMode: boolean;
selectedBooks: string[];
itemSelected: boolean;
transferProgress: number | null;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
toggleSelection: (hash: string) => void;
@@ -93,7 +93,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
item,
coverFit,
isSelectMode,
selectedBooks,
itemSelected,
transferProgress,
setLoading,
toggleSelection,
@@ -110,11 +110,12 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const { settings } = useSettingsStore();
const { updateBook } = useLibraryStore();
const showBookDetailsModal = async (book: Book) => {
const showBookDetailsModal = useCallback(async (book: Book) => {
if (await makeBookAvailable(book)) {
handleShowDetailsBook(book);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const makeBookAvailable = async (book: Book) => {
if (book.uploadedAt && !book.downloadedAt) {
@@ -122,7 +123,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
if (!book.downloadedAt || !book.coverDownloadedAt) {
book.downloadedAt = Date.now();
book.coverDownloadedAt = Date.now();
updateBook(envConfig, book);
await updateBook(envConfig, book);
}
return true;
}
@@ -130,7 +131,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const loadingTimeout = setTimeout(() => setLoading(true), 200);
try {
available = await handleBookDownload(book);
updateBook(envConfig, book);
await updateBook(envConfig, book);
} finally {
if (loadingTimeout) clearTimeout(loadingTimeout);
setLoading(false);
@@ -145,11 +146,14 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
if (isSelectMode) {
toggleSelection(book.hash);
} else {
if (!(await makeBookAvailable(book))) return;
const available = await makeBookAvailable(book);
if (!available) return;
if (appService?.hasWindow && settings.openBookInNewWindow) {
showReaderWindow(appService, [book.hash]);
} else {
navigateToReader(router, [book.hash]);
setTimeout(() => {
navigateToReader(router, [book.hash]);
}, 0);
}
}
},
@@ -164,7 +168,9 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
} else {
const params = new URLSearchParams(searchParams?.toString());
params.set('group', group.id);
navigateToLibrary(router, `${params.toString()}`);
setTimeout(() => {
navigateToLibrary(router, `${params.toString()}`);
}, 0);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -177,7 +183,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const fileRevealLabel =
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
const selectBookMenuItem = await MenuItem.new({
text: selectedBooks.includes(book.hash) ? _('Deselect Book') : _('Select Book'),
text: itemSelected ? _('Deselect Book') : _('Select Book'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
toggleSelection(book.hash);
@@ -231,7 +237,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const groupContextMenuHandler = async (group: BooksGroup) => {
if (!appService?.hasContextMenu) return;
const selectGroupMenuItem = await MenuItem.new({
text: selectedBooks.includes(group.id) ? _('Deselect Group') : _('Select Group'),
text: itemSelected ? _('Deselect Group') : _('Select Group'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
toggleSelection(group.id);
@@ -291,7 +297,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
groupContextMenuHandler(item as BooksGroup);
}
}, 100),
[],
[itemSelected],
);
const { pressing, handlers } = useLongPress(
@@ -335,14 +341,14 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
book={item}
coverFit={coverFit}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
bookSelected={itemSelected}
transferProgress={transferProgress}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
/>
) : (
<GroupItem group={item} isSelectMode={isSelectMode} selectedBooks={selectedBooks} />
<GroupItem group={item} isSelectMode={isSelectMode} groupSelected={itemSelected} />
)}
</div>
</div>
@@ -8,10 +8,10 @@ import BookCover from '@/components/BookCover';
interface GroupItemProps {
group: BooksGroup;
isSelectMode: boolean;
selectedBooks: string[];
groupSelected: boolean;
}
const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, selectedBooks }) => {
const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, groupSelected }) => {
const { appService } = useEnv();
const iconSize15 = useResponsiveSize(15);
@@ -30,12 +30,12 @@ const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, selectedBook
</div>
))}
</div>
{selectedBooks.includes(group.id) && (
{groupSelected && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
{isSelectMode && (
<div className='absolute bottom-1 right-1'>
{selectedBooks.includes(group.id) ? (
{groupSelected ? (
<MdCheckCircle className='fill-blue-500' />
) : (
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
+69 -61
View File
@@ -470,70 +470,78 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}));
}, 500);
const handleBookUpload = async (book: Book) => {
try {
await appService?.uploadBook(book, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
pushLibrary();
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book uploaded: {{title}}', {
title: book.title,
}),
});
return true;
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('Not authenticated') && settings.keepLogin) {
settings.keepLogin = false;
setSettings(settings);
navigateToLogin(router);
return false;
} else if (err.message.includes('Insufficient storage quota')) {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Insufficient storage quota'),
});
return false;
const handleBookUpload = useCallback(
async (book: Book) => {
try {
await appService?.uploadBook(book, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
pushLibrary();
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book uploaded: {{title}}', {
title: book.title,
}),
});
return true;
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('Not authenticated') && settings.keepLogin) {
settings.keepLogin = false;
setSettings(settings);
navigateToLogin(router);
return false;
} else if (err.message.includes('Insufficient storage quota')) {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Insufficient storage quota'),
});
return false;
}
}
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to upload book: {{title}}', {
title: book.title,
}),
});
return false;
}
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to upload book: {{title}}', {
title: book.title,
}),
});
return false;
}
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
);
const handleBookDownload = async (book: Book, redownload = false) => {
try {
await appService?.downloadBook(book, false, redownload, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book downloaded: {{title}}', {
title: book.title,
}),
});
return true;
} catch {
eventDispatcher.dispatch('toast', {
message: _('Failed to download book: {{title}}', {
title: book.title,
}),
type: 'error',
});
return false;
}
};
const handleBookDownload = useCallback(
async (book: Book, redownload = false) => {
try {
await appService?.downloadBook(book, false, redownload, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book downloaded: {{title}}', {
title: book.title,
}),
});
return true;
} catch {
eventDispatcher.dispatch('toast', {
message: _('Failed to download book: {{title}}', {
title: book.title,
}),
type: 'error',
});
return false;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
);
const handleBookDelete = async (book: Book) => {
try {
+93 -85
View File
@@ -1,6 +1,6 @@
import clsx from 'clsx';
import Image from 'next/image';
import { useEffect, useRef } from 'react';
import { memo, useEffect, useRef } from 'react';
import { Book } from '@/types/book';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { formatAuthors, formatTitle } from '@/utils/book';
@@ -14,102 +14,110 @@ interface BookCoverProps {
isPreview?: boolean;
}
const BookCover: React.FC<BookCoverProps> = ({
book,
mode = 'grid',
coverFit = 'crop',
className,
imageClassName,
isPreview,
}) => {
const coverRef = useRef<HTMLDivElement>(null);
const BookCover: React.FC<BookCoverProps> = memo<BookCoverProps>(
({ book, mode = 'grid', coverFit = 'crop', className, imageClassName, isPreview }) => {
const coverRef = useRef<HTMLDivElement>(null);
const toggleImageVisibility = (showImage: boolean) => {
if (coverRef.current) {
const coverImage = coverRef.current.querySelector('.cover-image');
const fallbackCover = coverRef.current.querySelector('.fallback-cover');
if (coverImage) {
coverImage.classList.toggle('invisible', !showImage);
const toggleImageVisibility = (showImage: boolean) => {
if (coverRef.current) {
const coverImage = coverRef.current.querySelector('.cover-image');
const fallbackCover = coverRef.current.querySelector('.fallback-cover');
if (coverImage) {
coverImage.classList.toggle('invisible', !showImage);
}
if (fallbackCover) {
fallbackCover.classList.toggle('invisible', showImage);
}
}
if (fallbackCover) {
fallbackCover.classList.toggle('invisible', showImage);
}
}
};
};
const handleImageError = () => {
toggleImageVisibility(false);
};
const handleImageError = () => {
toggleImageVisibility(false);
};
useEffect(() => {
toggleImageVisibility(true);
}, [book.metadata?.coverImageUrl, book.coverImageUrl]);
useEffect(() => {
toggleImageVisibility(true);
}, [book.metadata?.coverImageUrl, book.coverImageUrl]);
return (
<div
ref={coverRef}
className={clsx('book-cover-container relative flex h-full w-full', className)}
>
{coverFit === 'crop' ? (
<Image
src={book.metadata?.coverImageUrl || book.coverImageUrl!}
alt={book.title}
fill={true}
className={clsx('cover-image crop-cover-img object-cover', imageClassName)}
onError={handleImageError}
/>
) : (
<div
className={clsx(
'flex h-full w-full justify-center',
mode === 'grid' ? 'items-end' : 'items-center',
)}
>
return (
<div
ref={coverRef}
className={clsx('book-cover-container relative flex h-full w-full', className)}
>
{coverFit === 'crop' ? (
<Image
src={book.metadata?.coverImageUrl || book.coverImageUrl!}
alt={book.title}
width={0}
height={0}
sizes='100vw'
className={clsx(
'cover-image fit-cover-img h-auto max-h-full w-auto max-w-full shadow-md',
imageClassName,
)}
fill={true}
className={clsx('cover-image crop-cover-img object-cover', imageClassName)}
onError={handleImageError}
/>
</div>
)}
<div
className={clsx(
'fallback-cover invisible absolute inset-0 rounded-none p-2',
'text-neutral-content text-center font-serif font-medium',
isPreview ? 'bg-base-200/50' : 'bg-base-100',
) : (
<div
className={clsx(
'flex h-full w-full justify-center',
mode === 'grid' ? 'items-end' : 'items-center',
)}
>
<Image
src={book.metadata?.coverImageUrl || book.coverImageUrl!}
alt={book.title}
width={0}
height={0}
sizes='100vw'
className={clsx(
'cover-image fit-cover-img h-auto max-h-full w-auto max-w-full shadow-md',
imageClassName,
)}
onError={handleImageError}
/>
</div>
)}
>
<div className='flex h-1/2 items-center justify-center'>
<span
className={clsx(
isPreview ? 'line-clamp-2' : mode === 'grid' ? 'line-clamp-3' : 'line-clamp-2',
isPreview ? 'text-[0.5em]' : mode === 'grid' ? 'text-lg' : 'text-sm',
)}
>
{formatTitle(book.title)}
</span>
</div>
<div className='h-1/6'></div>
<div className='flex h-1/3 items-center justify-center'>
<span
className={clsx(
'text-neutral-content/50 line-clamp-1',
isPreview ? 'text-[0.4em]' : mode === 'grid' ? 'text-base' : 'text-xs',
)}
>
{formatAuthors(book.author)}
</span>
<div
className={clsx(
'fallback-cover invisible absolute inset-0 rounded-none p-2',
'text-neutral-content text-center font-serif font-medium',
isPreview ? 'bg-base-200/50' : 'bg-base-100',
)}
>
<div className='flex h-1/2 items-center justify-center'>
<span
className={clsx(
isPreview ? 'line-clamp-2' : mode === 'grid' ? 'line-clamp-3' : 'line-clamp-2',
isPreview ? 'text-[0.5em]' : mode === 'grid' ? 'text-lg' : 'text-sm',
)}
>
{formatTitle(book.title)}
</span>
</div>
<div className='h-1/6'></div>
<div className='flex h-1/3 items-center justify-center'>
<span
className={clsx(
'text-neutral-content/50 line-clamp-1',
isPreview ? 'text-[0.4em]' : mode === 'grid' ? 'text-base' : 'text-xs',
)}
>
{formatAuthors(book.author)}
</span>
</div>
</div>
</div>
</div>
);
};
);
},
(prevProps, nextProps) => {
return (
prevProps.book.coverImageUrl === nextProps.book.coverImageUrl &&
prevProps.book.metadata?.coverImageUrl === nextProps.book.metadata?.coverImageUrl &&
prevProps.mode === nextProps.mode &&
prevProps.coverFit === nextProps.coverFit &&
prevProps.isPreview === nextProps.isPreview &&
prevProps.className === nextProps.className &&
prevProps.imageClassName === nextProps.imageClassName
);
},
);
BookCover.displayName = 'BookCover';
export default BookCover;
@@ -72,6 +72,7 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
const book = library.splice(bookIndex, 1)[0]!;
book.progress = config.progress;
book.updatedAt = Date.now();
book.downloadedAt = book.downloadedAt || Date.now();
library.unshift(book);
setLibrary(library);
config.updatedAt = Date.now();
+23 -1
View File
@@ -7,6 +7,10 @@ interface LibraryState {
checkOpenWithBooks: boolean;
checkLastOpenBooks: boolean;
currentBookshelf: (Book | BooksGroup)[];
selectedBooks: Set<string>; // hashes for books, ids for groups
setSelectedBooks: (ids: string[]) => void;
getSelectedBooks: () => string[];
toggleSelectedBook: (id: string) => void;
getVisibleLibrary: () => Book[];
setCheckOpenWithBooks: (check: boolean) => void;
setCheckLastOpenBooks: (check: boolean) => void;
@@ -18,6 +22,7 @@ interface LibraryState {
export const useLibraryStore = create<LibraryState>((set, get) => ({
library: [],
currentBookshelf: [],
selectedBooks: new Set(),
checkOpenWithBooks: isTauriAppPlatform(),
checkLastOpenBooks: isTauriAppPlatform(),
getVisibleLibrary: () => get().library.filter((book) => !book.deletedAt),
@@ -35,6 +40,23 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
library[bookIndex] = book;
}
set({ library: [...library] });
appService.saveLibraryBooks(library);
await appService.saveLibraryBooks(library);
},
setSelectedBooks: (ids: string[]) => {
set({ selectedBooks: new Set(ids) });
},
getSelectedBooks: () => {
return Array.from(get().selectedBooks);
},
toggleSelectedBook: (id: string) => {
set((state) => {
const newSelection = new Set(state.selectedBooks);
if (newSelection.has(id)) {
newSelection.delete(id);
} else {
newSelection.add(id);
}
return { selectedBooks: newSelection };
});
},
}));