From cf41e7d50dfcc5c8eede6cf1868f00cfa93f131b Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 12 Jun 2026 01:24:46 +0800 Subject: [PATCH] feat(rsvp): apply reader font face/family settings to the RSVP word (#4519) (#4537) RSVP displayed the focal word in a hardcoded monospace font, ignoring the reader's configured font. Resolve the reader's body font-family (serif or sans-serif chain, per the "Default Font" setting, including the chosen typeface, CJK font, and any user-imported custom font) and apply it to the RSVP word display. Custom and additional fonts are already mounted in the top document where the overlay renders, so the resolved family resolves the same typeface. The monospace fallback is kept only when no font setting is available. Extracts the font-family list building from getFontStyles into a shared buildFontFamilyLists helper and exposes getBaseFontFamily for top-level UI. Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/rsvp-overlay-context.test.tsx | 27 ++++++++- .../components/rsvp-section-advance.test.tsx | 1 + .../utils/style-base-font-family.test.ts | 47 +++++++++++++++ .../reader/components/rsvp/RSVPControl.tsx | 10 +++- .../reader/components/rsvp/RSVPOverlay.tsx | 20 ++++++- apps/readest-app/src/utils/style.ts | 57 ++++++++++++++++--- 6 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 apps/readest-app/src/__tests__/utils/style-base-font-family.test.ts diff --git a/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx b/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx index 19ede700..d320c3c0 100644 --- a/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx +++ b/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx @@ -74,7 +74,7 @@ const buildController = (state: RsvpState) => { return controller; }; -const renderOverlay = (state: RsvpState) => { +const renderOverlay = (state: RsvpState, fontFamily?: string) => { const controller = buildController(state); const result = render( { controller={controller as unknown as RSVPController} chapters={[]} currentChapterHref={null} + fontFamily={fontFamily} onClose={vi.fn()} onChapterSelect={vi.fn()} onRequestNextPage={vi.fn()} @@ -142,6 +143,30 @@ describe('RSVPOverlay — context panel performance', () => { }); }); +describe('RSVPOverlay — reading font', () => { + afterEach(() => cleanup()); + + const wordState = () => + buildState({ words: [{ text: 'hello', orpIndex: 1, pauseMultiplier: 1 }], currentIndex: 0 }); + + test('applies the reader font family to the word display', () => { + const { container } = renderOverlay(wordState(), '"Bitter", "Source Han Serif CN", serif'); + const word = container.querySelector('.rsvp-word') as HTMLElement; + expect(word).not.toBeNull(); + expect(word.style.fontFamily).toContain('Bitter'); + // With a reading font supplied, the word no longer uses the monospace fallback. + expect(word.classList.contains('font-mono')).toBe(false); + }); + + test('falls back to the monospace class when no font family is supplied', () => { + const { container } = renderOverlay(wordState()); + const word = container.querySelector('.rsvp-word') as HTMLElement; + expect(word).not.toBeNull(); + expect(word.classList.contains('font-mono')).toBe(true); + expect(word.style.fontFamily).toBe(''); + }); +}); + describe('RSVPOverlay — progress bar drag on mobile', () => { afterEach(() => cleanup()); diff --git a/apps/readest-app/src/__tests__/components/rsvp-section-advance.test.tsx b/apps/readest-app/src/__tests__/components/rsvp-section-advance.test.tsx index 67203144..bb0254f8 100644 --- a/apps/readest-app/src/__tests__/components/rsvp-section-advance.test.tsx +++ b/apps/readest-app/src/__tests__/components/rsvp-section-advance.test.tsx @@ -44,6 +44,7 @@ vi.mock('@/store/readerStore', () => ({ location: 'epubcfi(/6/8!/4/1:0)', sectionHref: `ch${primaryIndex}.xhtml`, }), + getViewSettings: () => null, }), })); diff --git a/apps/readest-app/src/__tests__/utils/style-base-font-family.test.ts b/apps/readest-app/src/__tests__/utils/style-base-font-family.test.ts new file mode 100644 index 00000000..63cf7391 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/style-base-font-family.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; + +import { getBaseFontFamily } from '@/utils/style'; +import { ViewSettings } from '@/types/book'; +import { DEFAULT_BOOK_FONT } from '@/services/constants'; + +/** Build a minimal ViewSettings carrying just the font fields under test. */ +function makeFontSettings(overrides: Partial = {}): ViewSettings { + return { + ...DEFAULT_BOOK_FONT, + ...overrides, + } as ViewSettings; +} + +describe('getBaseFontFamily', () => { + it('returns the serif chain when defaultFont is "Serif"', () => { + const vs = makeFontSettings({ defaultFont: 'Serif', serifFont: 'Bitter' }); + const family = getBaseFontFamily(vs); + // The chosen serif typeface leads the chain and it ends with the generic. + expect(family.trimStart().startsWith('"Bitter"')).toBe(true); + expect(family.trimEnd().endsWith('serif')).toBe(true); + expect(family).not.toContain('sans-serif"'); + }); + + it('returns the sans-serif chain when defaultFont is "Sans-serif"', () => { + const vs = makeFontSettings({ defaultFont: 'Sans-serif', sansSerifFont: 'Roboto' }); + const family = getBaseFontFamily(vs); + expect(family.trimStart().startsWith('"Roboto"')).toBe(true); + expect(family.trimEnd().endsWith('sans-serif')).toBe(true); + }); + + it('places a custom serif font at the head of the chain', () => { + const vs = makeFontSettings({ defaultFont: 'Serif', serifFont: 'My Custom Font' }); + const family = getBaseFontFamily(vs); + expect(family.trimStart().startsWith('"My Custom Font"')).toBe(true); + }); + + it('includes the CJK font in the resolved chain', () => { + const vs = makeFontSettings({ + defaultFont: 'Serif', + serifFont: 'Bitter', + defaultCJKFont: 'Source Han Serif CN', + }); + const family = getBaseFontFamily(vs); + expect(family).toContain('"Source Han Serif CN"'); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx index 93415d05..c4d7324a 100644 --- a/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx @@ -13,6 +13,7 @@ import { buildRsvpExitConfigUpdate, } from '@/services/rsvp'; import { eventDispatcher } from '@/utils/event'; +import { getBaseFontFamily } from '@/utils/style'; import { useEnv } from '@/context/EnvContext'; import { useTranslation } from '@/hooks/useTranslation'; import { BookNote, PageInfo } from '@/types/book'; @@ -114,7 +115,7 @@ const RSVPControl: React.FC = ({ bookKey, gridInsets }) => { const _ = useTranslation(); const { envConfig } = useEnv(); const { settings } = useSettingsStore(); - const { getView, getProgress } = useReaderStore(); + const { getView, getProgress, getViewSettings } = useReaderStore(); const { getBookData, getConfig, setConfig, saveConfig } = useBookDataStore(); const { themeCode } = useThemeStore(); @@ -521,6 +522,12 @@ const RSVPControl: React.FC = ({ bookKey, gridInsets }) => { const chapters = bookData?.bookDoc?.toc || []; const currentChapterHref = rsvpChapterHrefRef.current ?? progress?.sectionHref ?? null; + // Mirror the reader's font face/family settings on the RSVP word. The overlay + // renders in the top document where the configured (and custom) fonts are + // already mounted, so the resolved family resolves the same typeface. + const viewSettings = getViewSettings(bookKey); + const fontFamily = viewSettings ? getBaseFontFamily(viewSettings) : undefined; + // Use portal to render overlay at body level to avoid stacking context issues const portalContainer = typeof document !== 'undefined' ? document.body : null; @@ -549,6 +556,7 @@ const RSVPControl: React.FC = ({ bookKey, gridInsets }) => { controller={controllerRef.current} chapters={chapters} currentChapterHref={currentChapterHref} + fontFamily={fontFamily} onClose={handleClose} onChapterSelect={handleChapterSelect} onRequestNextPage={handleRequestNextPage} diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx index 0ccef068..2248d4b1 100644 --- a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx @@ -78,6 +78,12 @@ interface RSVPOverlayProps { controller: RSVPController; chapters: TOCItem[]; currentChapterHref: string | null; + /** + * Resolved CSS font-family for the displayed word, mirroring the reader's + * font face/family settings. When undefined, the word keeps the monospace + * fallback. See getBaseFontFamily in utils/style. + */ + fontFamily?: string; onClose: () => void; onChapterSelect: (href: string) => void; onRequestNextPage: () => void; @@ -88,6 +94,7 @@ const RSVPOverlay: React.FC = ({ controller, chapters, currentChapterHref, + fontFamily, onClose, onChapterSelect, onRequestNextPage, @@ -710,8 +717,17 @@ const RSVPOverlay: React.FC = ({ {/* Word display */}
{currentWord ? ( isCJKWord && highlightWholeWord ? ( diff --git a/apps/readest-app/src/utils/style.ts b/apps/readest-app/src/utils/style.ts index 1be89043..f403498a 100644 --- a/apps/readest-app/src/utils/style.ts +++ b/apps/readest-app/src/utils/style.ts @@ -18,16 +18,18 @@ import { createFontCSS, CustomFont } from '@/styles/fonts'; import { getOSPlatform } from './misc'; import { SCROLL_WRAPPER_CLASS, SCROLL_WRAPPER_FIT_CLASS } from './scrollable'; -const getFontStyles = ( +/** + * Build the resolved CSS font-family lists (serif / sans-serif / monospace) + * from the user's font settings. Each value is a ready-to-use `font-family` + * string ending in the matching generic family. Shared by getFontStyles (which + * exposes them as CSS variables inside the reader iframe) and getBaseFontFamily + * (which applies the body font directly to top-level UI such as the RSVP overlay). + */ +const buildFontFamilyLists = ( serif: string, sansSerif: string, monospace: string, - defaultFont: string, defaultCJKFont: string, - fontSize: number, - minFontSize: number, - fontWeight: number, - overrideFont: boolean, ) => { const lastSerifFonts = ['Georgia', 'Times New Roman']; const serifFonts = [ @@ -50,12 +52,49 @@ const getFontStyles = ( ...FALLBACK_FONTS, ]; const monospaceFonts = [monospace, ...MONOSPACE_FONTS.filter((font) => font !== monospace)]; + const quote = (fonts: string[]) => fonts.map((font) => `"${font}"`).join(', '); + return { + serif: `${quote(serifFonts)}, serif`, + sansSerif: `${quote(sansSerifFonts)}, sans-serif`, + monospace: `${quote(monospaceFonts)}, monospace`, + }; +}; + +/** + * Resolve the body font-family string (serif or sans-serif chain, per the + * user's "Default Font" setting) for use outside the reader iframe — e.g. the + * RSVP overlay, which renders in the top document and can't read the iframe's + * --serif/--sans-serif CSS variables. Custom fonts are already mounted in the + * top document, so the returned chain resolves them by family name. + */ +export const getBaseFontFamily = (viewSettings: ViewSettings): string => { + const families = buildFontFamilyLists( + viewSettings.serifFont!, + viewSettings.sansSerifFont!, + viewSettings.monospaceFont!, + viewSettings.defaultCJKFont!, + ); + return viewSettings.defaultFont!.toLowerCase() === 'serif' ? families.serif : families.sansSerif; +}; + +const getFontStyles = ( + serif: string, + sansSerif: string, + monospace: string, + defaultFont: string, + defaultCJKFont: string, + fontSize: number, + minFontSize: number, + fontWeight: number, + overrideFont: boolean, +) => { + const families = buildFontFamilyLists(serif, sansSerif, monospace, defaultCJKFont); const defaultFontFamily = defaultFont.toLowerCase() === 'serif' ? '--serif' : '--sans-serif'; const fontStyles = ` html { - --serif: ${serifFonts.map((font) => `"${font}"`).join(', ')}, serif; - --sans-serif: ${sansSerifFonts.map((font) => `"${font}"`).join(', ')}, sans-serif; - --monospace: ${monospaceFonts.map((font) => `"${font}"`).join(', ')}, monospace; + --serif: ${families.serif}; + --sans-serif: ${families.sansSerif}; + --monospace: ${families.monospace}; --font-size: ${fontSize}px; --min-font-size: ${minFontSize}px; --font-weight: ${fontWeight};