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};