From ee01fcd1239742fe26bdb9f9fdbb30e0dd98b06e Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 12 Jun 2026 23:39:17 +0800 Subject: [PATCH] fix(reader): texture the scrolled-mode top inset mask, closes #4486 (#4563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../reader/components/SectionInfo.test.tsx | 70 +++++++++++++++++++ .../src/__tests__/styles/textures.test.ts | 19 +++++ .../src/app/reader/components/SectionInfo.tsx | 12 +++- apps/readest-app/src/styles/textures.ts | 2 +- 4 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 apps/readest-app/src/__tests__/app/reader/components/SectionInfo.test.tsx create mode 100644 apps/readest-app/src/__tests__/styles/textures.test.ts diff --git a/apps/readest-app/src/__tests__/app/reader/components/SectionInfo.test.tsx b/apps/readest-app/src/__tests__/app/reader/components/SectionInfo.test.tsx new file mode 100644 index 00000000..9d74d524 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/reader/components/SectionInfo.test.tsx @@ -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(); + 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(); + 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(); + 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); + }); +}); diff --git a/apps/readest-app/src/__tests__/styles/textures.test.ts b/apps/readest-app/src/__tests__/styles/textures.test.ts new file mode 100644 index 00000000..21c4d609 --- /dev/null +++ b/apps/readest-app/src/__tests__/styles/textures.test.ts @@ -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'); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/SectionInfo.tsx b/apps/readest-app/src/app/reader/components/SectionInfo.tsx index c466684a..7cc4938c 100644 --- a/apps/readest-app/src/app/reader/components/SectionInfo.tsx +++ b/apps/readest-app/src/app/reader/components/SectionInfo.tsx @@ -56,14 +56,20 @@ const SectionInfo: React.FC = ({ <>
{ } body::before, .sidebar-container::before, .notebook-container::before, - .foliate-viewer::before { + .foliate-viewer::before, .notch-masked::before { content: ""; position: absolute; top: 0;