From 8c50b33a9332c27d75beeae44d12edfd8ef070a5 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Wed, 1 Oct 2025 01:43:46 +0200 Subject: [PATCH] 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 --- src/features/theme/services/AppThemes.ts | 160 ++++++++++++++++++++--- 1 file changed, 143 insertions(+), 17 deletions(-) diff --git a/src/features/theme/services/AppThemes.ts b/src/features/theme/services/AppThemes.ts index 001052e2..73d6e183 100644 --- a/src/features/theme/services/AppThemes.ts +++ b/src/features/theme/services/AppThemes.ts @@ -42,11 +42,43 @@ export const getTheme = (id: AppThemes | undefined, customThemes: Record): boolean => Object.keys({ ...themes, ...customThemes }).every((themeId) => themeId.toLowerCase() !== id.toLowerCase()); -const getFontsFromTheme = (obj: Record, 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): 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]; @@ -54,43 +86,137 @@ const getFontsFromTheme = (obj: Record, 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, 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> = {}; 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 => + 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 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: () => {