Improve theme fonts loading
- Extract all fonts and all font weights from the theme - Load all required weights per font Not clear 100% what is the root cause. But the (missing font weights?) loaded fonts did cause some incorrect initial tooltip width measurement that lead to an out-of-viewport tooltip placement. Depending on the tooltip text and the monitor (size + scaling) the measurement might result in a fractal width. In case the tooltip was placed right on the horizontal end of the viewport this then lead to a horizontal scrollbar which potentially caused an additional vertical scrollbar resulting in a layout shift. Once the tooltip was rendered, the placement got corrected. Depending on the mouse placement, however, this issue caused an infinite loop of appearing and disappearing scrollbars. Fixes #1016
This commit is contained in:
@@ -42,11 +42,43 @@ 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 getFontsFromTheme = (obj: Record<string, any>, fonts: string[] = []): string[] => {
|
||||
const tmpFonts = [...fonts];
|
||||
const parseFontWeight = (value: any): number | null => {
|
||||
if (typeof value === 'number') return value;
|
||||
|
||||
const propertyNames = Object.keys(obj);
|
||||
for (const propertyName of propertyNames) {
|
||||
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];
|
||||
|
||||
@@ -54,43 +86,137 @@ const getFontsFromTheme = (obj: Record<string, any>, fonts: string[] = []): stri
|
||||
if (isValidFontProperty) {
|
||||
const detectedFonts = propertyValue.split(',') as string[];
|
||||
const normalizedFonts = detectedFonts.map((detectedFont) => detectedFont.replace(/"/g, '').trim());
|
||||
tmpFonts.push(...normalizedFonts);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const isObject = propertyType === 'object';
|
||||
if (isObject) {
|
||||
tmpFonts.push(...getFontsFromTheme(propertyValue as Record<string, any>, tmpFonts));
|
||||
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 [...new Set(tmpFonts)];
|
||||
return result;
|
||||
};
|
||||
|
||||
const loadedFonts: string[] = [];
|
||||
// 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 themeFonts = getFontsFromTheme(theme.typography ?? {});
|
||||
return !!themeFonts.length && themeFonts.some((font) => !loadedFonts.includes(font));
|
||||
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 themeFonts = getFontsFromTheme(theme.typography ?? {});
|
||||
const missingThemeFonts = themeFonts.filter((font) => !loadedFonts.includes(font));
|
||||
const fontWeightsMap = getFontsFromTheme(theme.typography ?? {});
|
||||
|
||||
if (!missingThemeFonts.length) {
|
||||
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: missingThemeFonts,
|
||||
families: webFontFamilies,
|
||||
},
|
||||
active: () => {
|
||||
loadedFonts.push(...missingThemeFonts);
|
||||
Object.entries(missingFontWeights).forEach(([fontName, weights]) => {
|
||||
usableFonts[fontName] ??= new Set();
|
||||
weights.forEach((weight) => usableFonts[fontName].add(weight));
|
||||
});
|
||||
loadFontsPromise.resolve();
|
||||
},
|
||||
inactive: () => {
|
||||
|
||||
Reference in New Issue
Block a user