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) <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
<RSVPOverlay
|
||||
@@ -82,6 +82,7 @@ const renderOverlay = (state: RsvpState) => {
|
||||
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());
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock('@/store/readerStore', () => ({
|
||||
location: 'epubcfi(/6/8!/4/1:0)',
|
||||
sectionHref: `ch${primaryIndex}.xhtml`,
|
||||
}),
|
||||
getViewSettings: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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> = {}): 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"');
|
||||
});
|
||||
});
|
||||
@@ -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<RSVPControlProps> = ({ 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<RSVPControlProps> = ({ 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<RSVPControlProps> = ({ bookKey, gridInsets }) => {
|
||||
controller={controllerRef.current}
|
||||
chapters={chapters}
|
||||
currentChapterHref={currentChapterHref}
|
||||
fontFamily={fontFamily}
|
||||
onClose={handleClose}
|
||||
onChapterSelect={handleChapterSelect}
|
||||
onRequestNextPage={handleRequestNextPage}
|
||||
|
||||
@@ -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<RSVPOverlayProps> = ({
|
||||
controller,
|
||||
chapters,
|
||||
currentChapterHref,
|
||||
fontFamily,
|
||||
onClose,
|
||||
onChapterSelect,
|
||||
onRequestNextPage,
|
||||
@@ -710,8 +717,17 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
|
||||
{/* Word display */}
|
||||
<div
|
||||
className='rsvp-word relative flex min-h-16 w-full items-center justify-center whitespace-nowrap px-2 py-2 font-mono font-medium leading-none tracking-wide sm:min-h-20 sm:px-4 sm:py-4'
|
||||
style={{ fontSize: `${currentFontSize}rem`, letterSpacing: wordLetterSpacing }}
|
||||
className={clsx(
|
||||
'rsvp-word relative flex min-h-16 w-full items-center justify-center whitespace-nowrap px-2 py-2 font-medium leading-none tracking-wide sm:min-h-20 sm:px-4 sm:py-4',
|
||||
// Fall back to a fixed-width font only when the reader has no
|
||||
// configured font face/family to apply.
|
||||
!fontFamily && 'font-mono',
|
||||
)}
|
||||
style={{
|
||||
fontSize: `${currentFontSize}rem`,
|
||||
letterSpacing: wordLetterSpacing,
|
||||
fontFamily,
|
||||
}}
|
||||
>
|
||||
{currentWord ? (
|
||||
isCJKWord && highlightWholeWord ? (
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user