fix(library): make bookitem-main shrink to match cover in fit mode (#4331)

* fix(library): make bookitem-main shrink to match cover in fit mode

Closes #4234. In fit mode the bookitem-main kept its 28/41 aspect
regardless of the cover image's natural aspect, leaving extra padding
beside (portrait covers) or above (landscape covers) the cover. The
select-mode overlay and icons drifted away from the cover edge.

BookCover now reports the loaded image's natural aspect ratio. BookItem
overrides the bookitem-main's aspect-ratio with the cover's aspect so
the box hugs the cover exactly, and proportionally shrinks book-item
width for portrait covers so the info row icons align with the cover's
right edge.

Also wrap the TTS "Back to TTS Location" pill with whitespace-nowrap so
long translations (e.g. German "Zurück zur TTS-Position") expand the
button width instead of overflowing the fixed height.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(library): scope cover shrink to bookitem-main, leave info row at cell width

Per review, the width shrink should only apply to the cover row so the
title and info icons keep their original cell-wide layout. Move the
width style from .book-item to .bookitem-main alongside its aspectRatio
override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-28 02:25:18 +08:00
committed by GitHub
parent a1cb228d00
commit 4e01e13ee7
4 changed files with 44 additions and 4 deletions
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import BookCover from '@/components/BookCover';
import { Book } from '@/types/book';
@@ -39,6 +39,21 @@ describe('BookCover', () => {
expect(img?.getAttribute('loading')).toBe('lazy');
});
it('reports natural aspect ratio via onAspectRatioChange when fit-mode image loads', () => {
const onAspectRatioChange = vi.fn();
const { container } = render(
<BookCover book={makeBook()} coverFit='fit' onAspectRatioChange={onAspectRatioChange} />,
);
const img = container.querySelector('img.cover-image') as HTMLImageElement;
expect(img).toBeTruthy();
Object.defineProperty(img, 'naturalWidth', { value: 600, configurable: true });
Object.defineProperty(img, 'naturalHeight', { value: 900, configurable: true });
fireEvent.load(img);
expect(onAspectRatioChange).toHaveBeenCalledWith(600 / 900);
});
it('falls back to metadata.author on the fallback cover when book.author is empty', () => {
const book = makeBook({
author: '',
@@ -1,4 +1,5 @@
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
import {
LiaCloudUploadAltSolid,
@@ -49,6 +50,21 @@ const BookItem: React.FC<BookItemProps> = ({
const { settings } = useSettingsStore();
const iconSize15 = useResponsiveSize(15);
const [coverAspect, setCoverAspect] = useState<number | null>(null);
useEffect(() => {
setCoverAspect(null);
}, [book.hash, book.metadata?.coverImageUrl, book.coverImageUrl]);
const CELL_ASPECT_RATIO = 28 / 41;
const fitCoverInGrid = mode === 'grid' && coverFit === 'fit' && coverAspect !== null;
const shouldShrinkWidth = fitCoverInGrid && coverAspect! < CELL_ASPECT_RATIO;
const bookitemMainStyle = fitCoverInGrid
? {
aspectRatio: coverAspect!,
...(shouldShrinkWidth ? { width: `${(coverAspect! / CELL_ASPECT_RATIO) * 100}%` } : {}),
}
: undefined;
return (
<div
role='none'
@@ -63,11 +79,13 @@ const BookItem: React.FC<BookItemProps> = ({
>
<div
className={clsx(
'bookitem-main relative flex aspect-[28/41] justify-center overflow-hidden rounded',
'bookitem-main relative flex justify-center overflow-hidden rounded',
!fitCoverInGrid && 'aspect-[28/41]',
coverFit === 'crop' && 'shadow-md',
mode === 'grid' && 'items-end',
mode === 'list' && 'min-w-20 items-center',
)}
style={bookitemMainStyle}
>
<BookCover
mode={mode}
@@ -75,6 +93,7 @@ const BookItem: React.FC<BookItemProps> = ({
coverFit={coverFit}
showSpine={false}
imageClassName='rounded shadow-md'
onAspectRatioChange={setCoverAspect}
/>
{bookSelected && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
@@ -175,7 +175,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
<button
onClick={tts.handleBackToCurrentTTSLocation}
className={clsx(
'not-eink:bg-base-300 eink-bordered rounded-full px-4 py-2 font-sans text-sm shadow-lg',
'not-eink:bg-base-300 eink-bordered whitespace-nowrap rounded-full px-4 py-2 font-sans text-sm shadow-lg',
safeAreaInsets?.top ? 'h-11' : 'h-9',
)}
>
@@ -14,6 +14,7 @@ interface BookCoverProps {
showSpine?: boolean;
isPreview?: boolean;
onImageError?: () => void;
onAspectRatioChange?: (ratio: number) => void;
}
const BookCover: React.FC<BookCoverProps> = memo<BookCoverProps>(
@@ -26,6 +27,7 @@ const BookCover: React.FC<BookCoverProps> = memo<BookCoverProps>(
imageClassName,
isPreview,
onImageError,
onAspectRatioChange,
}) => {
const coverRef = useRef<HTMLDivElement>(null);
const [imageLoaded, setImageLoaded] = useState(false);
@@ -46,10 +48,14 @@ const BookCover: React.FC<BookCoverProps> = memo<BookCoverProps>(
}
};
const handleImageLoad = () => {
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
setImageLoaded(true);
setImageError(false);
toggleImageVisibility(true);
const img = e.currentTarget;
if (onAspectRatioChange && img.naturalWidth > 0 && img.naturalHeight > 0) {
onAspectRatioChange(img.naturalWidth / img.naturalHeight);
}
};
const handleImageError = () => {