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:
schroda
2025-10-01 01:43:46 +02:00
parent 121c74ed73
commit 8c50b33a93

View File

@@ -42,11 +42,43 @@ export const getTheme = (id: AppThemes | undefined, customThemes: Record<string,
export const isThemeNameUnique = (id: string, customThemes: Record<string, AppTheme>): boolean => export const isThemeNameUnique = (id: string, customThemes: Record<string, AppTheme>): boolean =>
Object.keys({ ...themes, ...customThemes }).every((themeId) => themeId.toLowerCase() !== id.toLowerCase()); Object.keys({ ...themes, ...customThemes }).every((themeId) => themeId.toLowerCase() !== id.toLowerCase());
const getFontsFromTheme = (obj: Record<string, any>, fonts: string[] = []): string[] => { const parseFontWeight = (value: any): number | null => {
const tmpFonts = [...fonts]; if (typeof value === 'number') return value;
const propertyNames = Object.keys(obj); if (typeof value === 'string') {
for (const propertyName of propertyNames) { 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 propertyValue = obj[propertyName];
const propertyType = typeof obj[propertyName]; const propertyType = typeof obj[propertyName];
@@ -54,43 +86,137 @@ const getFontsFromTheme = (obj: Record<string, any>, fonts: string[] = []): stri
if (isValidFontProperty) { if (isValidFontProperty) {
const detectedFonts = propertyValue.split(',') as string[]; const detectedFonts = propertyValue.split(',') as string[];
const normalizedFonts = detectedFonts.map((detectedFont) => detectedFont.replace(/"/g, '').trim()); 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 // eslint-disable-next-line no-continue
continue; continue;
} }
const isObject = propertyType === 'object'; if (propertyType === 'object') {
if (isObject) { const nestedFonts = getFontsFromTheme(propertyValue, result);
tmpFonts.push(...getFontsFromTheme(propertyValue as Record<string, any>, tmpFonts)); 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 => { export const hasMissingFonts = (theme: CssVarsThemeOptions): boolean => {
const themeFonts = getFontsFromTheme(theme.typography ?? {}); const fontWeightsMap = getFontsFromTheme(theme.typography ?? {});
return !!themeFonts.length && themeFonts.some((font) => !loadedFonts.includes(font)); 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> => { export const loadThemeFonts = async (theme: CssVarsThemeOptions): Promise<void> => {
const themeFonts = getFontsFromTheme(theme.typography ?? {}); const fontWeightsMap = getFontsFromTheme(theme.typography ?? {});
const missingThemeFonts = themeFonts.filter((font) => !loadedFonts.includes(font));
if (!missingThemeFonts.length) { const missingFontWeights = await getMissingFontWeights(fontWeightsMap);
if (!Object.keys(missingFontWeights).length) {
return; 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(); const loadFontsPromise = new ControlledPromise();
try { try {
WebFont.load({ WebFont.load({
google: { google: {
families: missingThemeFonts, families: webFontFamilies,
}, },
active: () => { active: () => {
loadedFonts.push(...missingThemeFonts); Object.entries(missingFontWeights).forEach(([fontName, weights]) => {
usableFonts[fontName] ??= new Set();
weights.forEach((weight) => usableFonts[fontName].add(weight));
});
loadFontsPromise.resolve(); loadFontsPromise.resolve();
}, },
inactive: () => { inactive: () => {