Extract theme font loading logic
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<string,
|
||||
|
||||
export const isThemeNameUnique = (id: string, customThemes: Record<string, AppTheme>): 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<string, any>): number[] =>
|
||||
Object.entries(obj)
|
||||
.filter(([key]) => key.startsWith('fontWeight'))
|
||||
.map(([, value]) => parseFontWeight(value))
|
||||
.filter((weight) => weight !== null);
|
||||
|
||||
const getFontsFromTheme = (
|
||||
obj: Record<string, any>,
|
||||
fonts: Record<string, Set<number>> = {},
|
||||
): Record<string, Set<number>> => {
|
||||
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<string, Set<number>> = {};
|
||||
|
||||
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<boolean> =>
|
||||
document.fonts.check(`${weight} 12px "${fontName}"`);
|
||||
|
||||
const getMissingFontWeights = async (
|
||||
fontWeightsMap: Record<string, Set<number>>,
|
||||
): Promise<Record<string, Set<number>>> => {
|
||||
const pendingMissingFontWeightsEntries = Object.entries(fontWeightsMap).map(async ([fontName, requiredWeights]) => {
|
||||
if (GENERIC_FONT_FAMILIES.has(fontName.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usableWeights = usableFonts[fontName] ?? new Set<number>();
|
||||
|
||||
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<number>][]);
|
||||
};
|
||||
|
||||
export const loadThemeFonts = async (theme: CssVarsThemeOptions): Promise<void> => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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<typeof createTheme>) => {
|
||||
theme = createTheme(...args);
|
||||
loadThemeFonts(theme).catch(defaultPromiseErrorHandler('theme::createAndSetTheme'));
|
||||
ThemeFontLoader.load(theme).catch(defaultPromiseErrorHandler('theme::createAndSetTheme'));
|
||||
|
||||
return theme;
|
||||
};
|
||||
|
||||
203
src/features/theme/services/ThemeFontLoader.ts
Normal file
203
src/features/theme/services/ThemeFontLoader.ts
Normal file
@@ -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<string, Set<number>> = {};
|
||||
|
||||
private static async isInstalled(fontName: string, weight: number | string): Promise<boolean> {
|
||||
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<string, any>): number[] {
|
||||
return Object.entries(obj)
|
||||
.filter(([key]) => key.startsWith('fontWeight'))
|
||||
.map(([, value]) => this.parseWeight(value))
|
||||
.filter((weight) => weight !== null);
|
||||
}
|
||||
|
||||
private static extract(
|
||||
obj: Record<string, any>,
|
||||
fonts: Record<string, Set<number>> = {},
|
||||
): Record<string, Set<number>> {
|
||||
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<string, Set<number>>): Promise<Record<string, Set<number>>> {
|
||||
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<number>();
|
||||
|
||||
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<number>][]);
|
||||
}
|
||||
|
||||
static async load(theme: CssVarsThemeOptions): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user