diff --git a/src/features/theme/components/ThemePreview.tsx b/src/features/theme/components/ThemePreview.tsx index 3a5b1d4c..75a28f1d 100644 --- a/src/features/theme/components/ThemePreview.tsx +++ b/src/features/theme/components/ThemePreview.tsx @@ -20,12 +20,13 @@ import EditIcon from '@mui/icons-material/Edit'; import { bindDialog, usePopupState } from 'material-ui-popup-state/hooks'; import { CustomTooltip } from '@/base/components/CustomTooltip.tsx'; import { useAppThemeContext } from '@/features/theme/AppThemeContext.tsx'; -import { AppTheme, hasMissingFonts, loadThemeFonts } from '@/features/theme/services/AppThemes.ts'; +import { AppTheme } from '@/features/theme/services/AppThemes.ts'; import { createTheme } from '@/features/theme/services/ThemeCreator.ts'; import { ThemeCreationDialog } from '@/features/theme/components/CreateThemeDialog.tsx'; import { makeToast } from '@/base/utils/Toast.ts'; import { TypographyMaxLines } from '@/base/components/texts/TypographyMaxLines.tsx'; import { getErrorMessage } from '@/lib/HelperFunctions.ts'; +import { ThemeFontLoader } from '@/features/theme/services/ThemeFontLoader.ts'; const ThemePreviewBadge = styled(Box)(() => ({ width: '15px', @@ -92,14 +93,14 @@ export const ThemePreview = ({ appTheme, onDelete }: { appTheme: AppTheme; onDel backgroundColor: 'background.default', }} onClick={() => { - const needToLoadFonts = hasMissingFonts(appTheme.muiTheme); + const needToLoadFonts = ThemeFontLoader.hasMissing(appTheme.muiTheme); if (!needToLoadFonts) { setAppTheme(appTheme.id); return; } makeToast(t('settings.appearance.theme.select.fonts.loading'), 'info'); - loadThemeFonts(appTheme.muiTheme) + ThemeFontLoader.load(appTheme.muiTheme) .then(() => setAppTheme(appTheme.id)) .catch((e) => makeToast( diff --git a/src/features/theme/services/AppThemes.ts b/src/features/theme/services/AppThemes.ts index 73d6e183..bab2b66f 100644 --- a/src/features/theme/services/AppThemes.ts +++ b/src/features/theme/services/AppThemes.ts @@ -6,10 +6,6 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { CssVarsThemeOptions } from '@mui/material/styles'; -import WebFont from 'webfontloader'; -import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; -import { ControlledPromise } from '@/lib/ControlledPromise.ts'; import { TBaseTheme, themes } from '@/features/theme/Themes.ts'; export type AppThemes = keyof typeof themes | string; @@ -41,192 +37,3 @@ export const getTheme = (id: AppThemes | undefined, customThemes: Record): boolean => Object.keys({ ...themes, ...customThemes }).every((themeId) => themeId.toLowerCase() !== id.toLowerCase()); - -const parseFontWeight = (value: any): number | null => { - if (typeof value === 'number') return value; - - if (typeof value === 'string') { - const parsed = Number(value); - if (!Number.isNaN(parsed)) return parsed; - - // Handle CSS keywords - switch (value.toLowerCase()) { - case 'light': - return 300; - case 'regular': - return 400; - case 'medium': - return 500; - case 'bold': - return 700; - default: // Fall through - } - } - - return null; -}; - -const extractFontWeightsFromObject = (obj: Record): number[] => - Object.entries(obj) - .filter(([key]) => key.startsWith('fontWeight')) - .map(([, value]) => parseFontWeight(value)) - .filter((weight) => weight !== null); - -const getFontsFromTheme = ( - obj: Record, - fonts: Record> = {}, -): Record> => { - const result = { ...fonts }; - - for (const propertyName of Object.keys(obj)) { - const propertyValue = obj[propertyName]; - const propertyType = typeof obj[propertyName]; - - const isValidFontProperty = propertyName === 'fontFamily' && propertyType === 'string'; - if (isValidFontProperty) { - const detectedFonts = propertyValue.split(',') as string[]; - const normalizedFonts = detectedFonts.map((detectedFont) => detectedFont.replace(/"/g, '').trim()); - - const weights = extractFontWeightsFromObject(obj); - - normalizedFonts.forEach((font) => { - result[font] = new Set([...(result[font] ?? []), ...weights].toSorted((a, b) => a - b)); - }); - - // eslint-disable-next-line no-continue - continue; - } - - if (propertyType === 'object') { - const nestedFonts = getFontsFromTheme(propertyValue, result); - Object.entries(nestedFonts).forEach(([font, weights]) => { - result[font] = new Set([...(result[font] ?? []), ...weights].toSorted((a, b) => a - b)); - }); - } - } - - return result; -}; - -// https://developer.mozilla.org/en-US/docs/Web/CSS/generic-family -const GENERIC_FONT_FAMILIES = new Set([ - 'serif', - 'sans-serif', - 'monospace', - 'cursive', - 'fantasy', - 'system-ui', - 'ui-serif', - 'ui-sans-serif', - 'ui-monospace', - 'ui-rounded', - 'math', - 'emoji', - 'fangsong', -]); - -const usableFonts: Record> = {}; - -export const hasMissingFonts = (theme: CssVarsThemeOptions): boolean => { - const fontWeightsMap = getFontsFromTheme(theme.typography ?? {}); - const fontNames = Object.keys(fontWeightsMap); - - return fontNames.some((fontName) => { - if (GENERIC_FONT_FAMILIES.has(fontName.toLowerCase())) { - return false; - } - - const requiredWeights = fontWeightsMap[fontName]; - const usableWeights = usableFonts[fontName]; - - if (!usableWeights) { - return true; - } - - return Array.from(requiredWeights).some((weight) => !usableWeights.has(weight)); - }); -}; - -const isFontInstalled = async (fontName: string, weight: number | string): Promise => - document.fonts.check(`${weight} 12px "${fontName}"`); - -const getMissingFontWeights = async ( - fontWeightsMap: Record>, -): Promise>> => { - const pendingMissingFontWeightsEntries = Object.entries(fontWeightsMap).map(async ([fontName, requiredWeights]) => { - if (GENERIC_FONT_FAMILIES.has(fontName.toLowerCase())) { - return null; - } - - const usableWeights = usableFonts[fontName] ?? new Set(); - - const weightChecks = await Promise.all( - Array.from(requiredWeights).map(async (weight) => { - if (usableWeights.has(weight)) { - return null; - } - - if (!(await isFontInstalled(fontName, weight))) { - return null; - } - - return weight; - }), - ); - - const missingWeights = new Set(weightChecks.filter((weight) => weight !== null)); - if (!missingWeights.size) { - return null; - } - - return [fontName, missingWeights]; - }); - const missingFontWeightsEntries = await Promise.all(pendingMissingFontWeightsEntries); - - return Object.fromEntries(missingFontWeightsEntries.filter(Boolean) as [string, Set][]); -}; - -export const loadThemeFonts = async (theme: CssVarsThemeOptions): Promise => { - const fontWeightsMap = getFontsFromTheme(theme.typography ?? {}); - - const missingFontWeights = await getMissingFontWeights(fontWeightsMap); - - if (!Object.keys(missingFontWeights).length) { - return; - } - - // Transform to WebFont format: "Roboto:400,700" - const webFontFamilies = Object.entries(missingFontWeights).map(([font, weights]) => { - const weightsArray = Array.from(weights); - - if (!weightsArray.length) { - return font; - } - - return `${font}:${weightsArray.join(',')}`; - }); - - const loadFontsPromise = new ControlledPromise(); - try { - WebFont.load({ - google: { - families: webFontFamilies, - }, - active: () => { - Object.entries(missingFontWeights).forEach(([fontName, weights]) => { - usableFonts[fontName] ??= new Set(); - weights.forEach((weight) => usableFonts[fontName].add(weight)); - }); - loadFontsPromise.resolve(); - }, - inactive: () => { - loadFontsPromise.reject(); - }, - }); - } catch (e) { - defaultPromiseErrorHandler('AppThemes::loadFonts')(e); - loadFontsPromise.reject(e); - } - - await loadFontsPromise.promise; -}; diff --git a/src/features/theme/services/ThemeCreator.ts b/src/features/theme/services/ThemeCreator.ts index f5a1b51c..66d8b6a5 100644 --- a/src/features/theme/services/ThemeCreator.ts +++ b/src/features/theme/services/ThemeCreator.ts @@ -23,10 +23,11 @@ import { deepmerge } from '@mui/utils'; import { PaletteBackgroundChannel } from '@mui/material/styles/createThemeWithVars'; import { Palette } from '@vibrant/color'; import { MediaQuery } from '@/base/utils/MediaQuery.tsx'; -import { AppTheme, loadThemeFonts } from '@/features/theme/services/AppThemes.ts'; +import { AppTheme } from '@/features/theme/services/AppThemes.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { applyStyles } from '@/base/utils/ApplyStyles.ts'; import { TAppThemeContext, ThemeMode } from '@/features/theme/AppTheme.types.ts'; +import { ThemeFontLoader } from '@/features/theme/services/ThemeFontLoader.ts'; const SCROLLBAR_SIZE = 14; @@ -276,7 +277,7 @@ let theme: Theme; export const getCurrentTheme = () => theme; export const createAndSetTheme = (...args: Parameters) => { theme = createTheme(...args); - loadThemeFonts(theme).catch(defaultPromiseErrorHandler('theme::createAndSetTheme')); + ThemeFontLoader.load(theme).catch(defaultPromiseErrorHandler('theme::createAndSetTheme')); return theme; }; diff --git a/src/features/theme/services/ThemeFontLoader.ts b/src/features/theme/services/ThemeFontLoader.ts new file mode 100644 index 00000000..42dc986c --- /dev/null +++ b/src/features/theme/services/ThemeFontLoader.ts @@ -0,0 +1,203 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import WebFont from 'webfontloader'; +import { CssVarsThemeOptions } from '@mui/material/styles'; +import { ControlledPromise } from '@/lib/ControlledPromise.ts'; +import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; + +export class ThemeFontLoader { + // https://developer.mozilla.org/en-US/docs/Web/CSS/generic-family + private static readonly GENERIC_FONT_FAMILIES: readonly string[] = [ + 'serif', + 'sans-serif', + 'monospace', + 'cursive', + 'fantasy', + 'system-ui', + 'ui-serif', + 'ui-sans-serif', + 'ui-monospace', + 'ui-rounded', + 'math', + 'emoji', + 'fangsong', + ]; + + private static readonly usableFonts: Record> = {}; + + private static async isInstalled(fontName: string, weight: number | string): Promise { + return document.fonts.check(`${weight} 12px "${fontName}"`); + } + + private static parseWeight(value: any): number | null { + if (typeof value === 'number') return value; + + if (typeof value === 'string') { + const parsed = Number(value); + if (!Number.isNaN(parsed)) return parsed; + + // Handle CSS keywords + switch (value.toLowerCase()) { + case 'light': + return 300; + case 'regular': + return 400; + case 'medium': + return 500; + case 'bold': + return 700; + default: // Fall through + } + } + + return null; + } + + private static extractWeights(obj: Record): number[] { + return Object.entries(obj) + .filter(([key]) => key.startsWith('fontWeight')) + .map(([, value]) => this.parseWeight(value)) + .filter((weight) => weight !== null); + } + + private static extract( + obj: Record, + fonts: Record> = {}, + ): Record> { + const result = { ...fonts }; + + for (const propertyName of Object.keys(obj)) { + const propertyValue = obj[propertyName]; + const propertyType = typeof obj[propertyName]; + + const isValidFontProperty = propertyName === 'fontFamily' && propertyType === 'string'; + if (isValidFontProperty) { + const detectedFonts = propertyValue.split(',') as string[]; + const normalizedFonts = detectedFonts.map((detectedFont) => detectedFont.replace(/"/g, '').trim()); + + const weights = this.extractWeights(obj); + + normalizedFonts.forEach((font) => { + result[font] = new Set([...(result[font] ?? []), ...weights].toSorted((a, b) => a - b)); + }); + + // eslint-disable-next-line no-continue + continue; + } + + if (propertyType === 'object') { + const nestedFonts = this.extract(propertyValue, result); + Object.entries(nestedFonts).forEach(([font, weights]) => { + result[font] = new Set([...(result[font] ?? []), ...weights].toSorted((a, b) => a - b)); + }); + } + } + + return result; + } + + static hasMissing(theme: CssVarsThemeOptions): boolean { + const fontWeightsMap = this.extract(theme.typography ?? {}); + const fontNames = Object.keys(fontWeightsMap); + + return fontNames.some((fontName) => { + if (this.GENERIC_FONT_FAMILIES.includes(fontName.toLowerCase())) { + return false; + } + + const requiredWeights = fontWeightsMap[fontName]; + const usableWeights = this.usableFonts[fontName]; + + if (!usableWeights) { + return true; + } + + return Array.from(requiredWeights).some((weight) => !usableWeights.has(weight)); + }); + } + + private static async detectMissing(fonts: Record>): Promise>> { + const pendingMissingFontWeightsEntries = Object.entries(fonts).map(async ([fontName, requiredWeights]) => { + if (this.GENERIC_FONT_FAMILIES.includes(fontName.toLowerCase())) { + return null; + } + + const usableWeights = this.usableFonts[fontName] ?? new Set(); + + const weightChecks = await Promise.all( + Array.from(requiredWeights).map(async (weight) => { + if (usableWeights.has(weight)) { + return null; + } + + if (!(await this.isInstalled(fontName, weight))) { + return null; + } + + return weight; + }), + ); + + const missingWeights = new Set(weightChecks.filter((weight) => weight !== null)); + if (!missingWeights.size) { + return null; + } + + return [fontName, missingWeights]; + }); + const missingFontWeightsEntries = await Promise.all(pendingMissingFontWeightsEntries); + + return Object.fromEntries(missingFontWeightsEntries.filter(Boolean) as [string, Set][]); + } + + static async load(theme: CssVarsThemeOptions): Promise { + const fontWeightsMap = this.extract(theme.typography ?? {}); + + const missingFontWeights = await this.detectMissing(fontWeightsMap); + + if (!Object.keys(missingFontWeights).length) { + return; + } + + // Transform to WebFont format: "Roboto:400,700" + const webFontFamilies = Object.entries(missingFontWeights).map(([font, weights]) => { + const weightsArray = Array.from(weights); + + if (!weightsArray.length) { + return font; + } + + return `${font}:${weightsArray.join(',')}`; + }); + + const loadFontsPromise = new ControlledPromise(); + try { + WebFont.load({ + google: { + families: webFontFamilies, + }, + active: () => { + Object.entries(missingFontWeights).forEach(([fontName, weights]) => { + this.usableFonts[fontName] ??= new Set(); + weights.forEach((weight) => this.usableFonts[fontName].add(weight)); + }); + loadFontsPromise.resolve(); + }, + inactive: () => { + loadFontsPromise.reject(); + }, + }); + } catch (e) { + defaultPromiseErrorHandler('ThemeFontLoader::load')(e); + loadFontsPromise.reject(e); + } + + await loadFontsPromise.promise; + } +}