fix(reader): texture the scrolled-mode top inset mask, closes #4486 (#4563)

In scrolled mode the notch-area masks the top safe-area inset with
opaque bg-base-100 so content scrolling under the status bar is hidden,
but it painted over the background texture (.foliate-viewer::before at
the z-0 layer), leaving a flat untextured strip across the unsafe
header area.

Give the mask its own texture ::before (.notch-masked in textures.ts)
and make the element span the grid cell, clipped down to the inset
strip with clip-path — background-size cover/contain resolves against
the element box, so the full-cell box is what keeps the mask's tiles
aligned with the viewer's at the seam. clip-path also clips
hit-testing, so the click target stays the inset strip only.

Verified on a Xiaomi 13: the strip now renders the texture with a
pixel-continuous seam (row-to-row MAE at the boundary dropped from
11913 to 230, the level of ordinary texture rows), and
elementsFromPoint confirms the notch is hit-testable only inside the
strip.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-12 23:39:17 +08:00
committed by GitHub
parent 59d4f0aa33
commit ee01fcd123
4 changed files with 99 additions and 4 deletions
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import SectionInfo from '@/app/reader/components/SectionInfo';
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: { isAndroidApp: false, isMobile: true } }),
}));
vi.mock('@/store/themeStore', () => ({
useThemeStore: () => ({ systemUIVisible: false, statusBarHeight: 0 }),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({
hoveredBookKey: '',
getView: () => null,
getViewSettings: () => ({ marginTopPx: 44 }),
setHoveredBookKey: vi.fn(),
}),
}));
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (key: string) => key,
}));
const baseProps = {
bookKey: 'book-1',
section: 'Chapter 1',
showDoubleBorder: false,
isScrolled: true,
isVertical: false,
isEink: false,
horizontalGap: 5,
contentInsets: { top: 89, right: 0, bottom: 0, left: 0 },
gridInsets: { top: 45, right: 0, bottom: 0, left: 0 },
};
describe('SectionInfo notch mask', () => {
it('spans the grid cell and clips to the top inset so its texture aligns with the viewer (#4486)', () => {
// The scrolled-mode notch mask hides content scrolling under the status bar,
// but must not occlude the background texture. Its texture ::before only
// tile-aligns with .foliate-viewer::before (background-size cover/contain
// resolves against the element box) if the notch shares the viewer's paint
// box: full grid cell, with the visible/hit area clipped to the inset strip.
const { container } = render(<SectionInfo {...baseProps} />);
const notch = container.querySelector('.notch-area') as HTMLElement;
expect(notch).not.toBeNull();
expect(notch.classList.contains('inset-0')).toBe(true);
expect(notch.classList.contains('notch-masked')).toBe(true);
expect(notch.classList.contains('bg-base-100')).toBe(true);
expect(notch.style.clipPath).toBe('inset(0 0 calc(100% - 45px) 0)');
});
it('keeps the notch transparent and untextured in paginated mode', () => {
const { container } = render(<SectionInfo {...baseProps} isScrolled={false} />);
const notch = container.querySelector('.notch-area') as HTMLElement;
expect(notch.classList.contains('notch-masked')).toBe(false);
expect(notch.classList.contains('bg-base-100')).toBe(false);
});
it('keeps the notch transparent and untextured in vertical scrolled mode', () => {
const { container } = render(<SectionInfo {...baseProps} isVertical={true} />);
const notch = container.querySelector('.notch-area') as HTMLElement;
expect(notch.classList.contains('notch-masked')).toBe(false);
expect(notch.classList.contains('bg-base-100')).toBe(false);
});
});
@@ -0,0 +1,19 @@
import { describe, it, expect, afterEach } from 'vitest';
import { mountBackgroundTexture, unmountBackgroundTexture } from '@/styles/textures';
afterEach(() => {
unmountBackgroundTexture(document);
});
describe('mountBackgroundTexture', () => {
it('covers the scrolled-mode notch mask so the top inset strip is textured (#4486)', () => {
mountBackgroundTexture(document, {
id: 'paper',
name: 'Paper',
url: '/images/paper-texture.png',
});
const style = document.getElementById('background-texture');
expect(style?.textContent).toContain('.notch-masked::before');
});
});
@@ -56,14 +56,20 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
<>
<div
className={clsx(
'notch-area absolute left-0 right-0 top-0 z-10',
isScrolled && !isVertical && 'bg-base-100',
// Spans the grid cell and clips down to the top inset strip so the
// texture ::before (.notch-masked, see styles/textures.ts) shares
// .foliate-viewer::before's paint box — background-size cover/contain
// resolves against the element box, so a strip-sized box would
// mis-tile at the seam (#4486). clip-path also clips hit-testing,
// keeping the click target the inset strip only.
'notch-area absolute inset-0 z-10',
isScrolled && !isVertical && 'notch-masked bg-base-100',
)}
role='none'
tabIndex={-1}
onClick={handleNotchClick}
style={{
height: `${topInset}px`,
clipPath: `inset(0 0 calc(100% - ${topInset}px) 0)`,
}}
/>
<div
+1 -1
View File
@@ -94,7 +94,7 @@ const createTextureCSS = (texture: BackgroundTexture) => {
}
body::before, .sidebar-container::before, .notebook-container::before,
.foliate-viewer::before {
.foliate-viewer::before, .notch-masked::before {
content: "";
position: absolute;
top: 0;