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 =>
|
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: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user