diff --git a/package.json b/package.json index 8fd5c1da..fc55917d 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,8 @@ "stylis": "4.3.4", "stylis-plugin-rtl": "2.1.1", "use-long-press": "3.2.0", - "use-query-params": "2.2.1" + "use-query-params": "2.2.1", + "webfontloader": "1.6.28" }, "devDependencies": { "@graphql-codegen/cli": "5.0.2", @@ -78,6 +79,7 @@ "@types/react-dom": "18.3.0", "@types/sanitize-html": "2.13.0", "@types/stylis": "4.2.6", + "@types/webfontloader": "1.6.38", "@types/yargs": "17.0.33", "@typescript-eslint/eslint-plugin": "7.16.1", "@typescript-eslint/parser": "7.16.1", diff --git a/public/locales/en.json b/public/locales/en.json index 4edd047a..2586e761 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -850,6 +850,13 @@ "title": "Edit theme" }, "pure_black_mode": "Pure black dark mode", + "select": { + "fonts": { + "error": "Could not load theme fonts", + "loading": "Loading theme fonts…" + }, + "success": "Selected theme" + }, "themes": { "crimson": "Crimson", "dune": "Dune", diff --git a/src/lib/ui/AppThemes.ts b/src/lib/ui/AppThemes.ts index 28390cd7..ac3573d5 100644 --- a/src/lib/ui/AppThemes.ts +++ b/src/lib/ui/AppThemes.ts @@ -8,7 +8,9 @@ import { ThemeOptions } from '@mui/material/styles'; import { t as translate } from 'i18next'; +import WebFont from 'webfontloader'; import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts'; +import { ControlledPromise } from '@/lib/ControlledPromise.ts'; type Theme = { isCustom: boolean; getName: () => string; muiTheme: ThemeOptions }; @@ -168,3 +170,58 @@ export const getTheme = (id: AppThemes, customThemes: Record = export const isThemeNameUnique = (id: string, customThemes: Record): boolean => Object.keys({ ...themes, ...customThemes }).every((themeId) => themeId.toLowerCase() !== id.toLowerCase()); + +const getFontsFromTheme = (obj: Record, fonts: string[] = []): string[] => { + const tmpFonts = [...fonts]; + + const propertyNames = Object.keys(obj); + for (const propertyName of propertyNames) { + const propertyValue = obj[propertyName]; + const propertyType = typeof obj[propertyName]; + + const isValidFontProperty = propertyName === 'fontFamily' && propertyType === 'string'; + if (isValidFontProperty) { + tmpFonts.push(propertyValue as string); + // eslint-disable-next-line no-continue + continue; + } + + const isObject = propertyType === 'object'; + if (isObject) { + tmpFonts.push(...getFontsFromTheme(propertyValue as Record, tmpFonts)); + } + } + + return [...new Set(tmpFonts)]; +}; + +const loadedFonts: string[] = []; +export const loadThemeFonts = async (theme: ThemeOptions): Promise => { + const themeFonts = getFontsFromTheme(theme); + const missingThemeFonts = themeFonts.filter((font) => !loadedFonts.includes(font)); + + if (!missingThemeFonts.length) { + return; + } + + const loadFontsPromise = new ControlledPromise(); + try { + WebFont.load({ + google: { + families: missingThemeFonts, + }, + active: () => { + loadedFonts.push(...missingThemeFonts); + loadFontsPromise.resolve(); + }, + inactive: () => { + loadFontsPromise.reject(); + }, + }); + } catch (e) { + defaultPromiseErrorHandler('AppThemes::loadFonts')(e); + loadFontsPromise.reject(e); + } + + await loadFontsPromise.promise; +}; diff --git a/src/screens/settings/appearance/theme/ThemePreview.tsx b/src/screens/settings/appearance/theme/ThemePreview.tsx index f907941f..50120dde 100644 --- a/src/screens/settings/appearance/theme/ThemePreview.tsx +++ b/src/screens/settings/appearance/theme/ThemePreview.tsx @@ -21,9 +21,10 @@ import Tooltip from '@mui/material/Tooltip'; import EditIcon from '@mui/icons-material/Edit'; import { bindDialog, usePopupState } from 'material-ui-popup-state/hooks'; import { ThemeModeContext } from '@/components/context/ThemeModeContext.tsx'; -import { AppTheme } from '@/lib/ui/AppThemes.ts'; +import { AppTheme, loadThemeFonts } from '@/lib/ui/AppThemes.ts'; import { createTheme } from '@/theme.ts'; import { ThemeCreationDialog } from '@/screens/settings/appearance/theme/CreateThemeDialog.tsx'; +import { makeToast } from '@/components/util/Toast.tsx'; const ThemePreviewBadge = styled(Box)(() => ({ width: '15px', @@ -82,7 +83,15 @@ export const ThemePreview = ({ theme, onDelete }: { theme: AppTheme; onDelete: ( height: '100%', backgroundColor: 'background.default', }} - onClick={() => setAppTheme(theme.id)} + onClick={() => { + makeToast(t('settings.appearance.theme.select.fonts.loading'), 'info'); + loadThemeFonts(theme.muiTheme) + .then(() => { + setAppTheme(theme.id); + makeToast(t('settings.appearance.theme.select.success'), 'success'); + }) + .catch(() => makeToast(t('settings.appearance.theme.select.fonts.error'), 'error')); + }} > diff --git a/src/theme.ts b/src/theme.ts index a372e65c..bb3a5047 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -17,7 +17,8 @@ import { } from '@mui/material/styles'; import { ThemeMode } from '@/components/context/ThemeModeContext.tsx'; import { MediaQuery } from '@/lib/ui/MediaQuery.tsx'; -import { AppTheme } from '@/lib/ui/AppThemes.ts'; +import { AppTheme, loadThemeFonts } from '@/lib/ui/AppThemes.ts'; +import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts'; const SCROLLBAR_SIZE = 14; @@ -106,6 +107,8 @@ let theme: Theme; export const getCurrentTheme = () => theme; export const createAndSetTheme = (...args: Parameters) => { theme = createTheme(...args); + loadThemeFonts(theme).catch(defaultPromiseErrorHandler('theme::createAndSetTheme')); + return theme; }; diff --git a/yarn.lock b/yarn.lock index dc5c3f5a..6bedebd5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3161,6 +3161,11 @@ resolved "https://registry.yarnpkg.com/@types/stylis/-/stylis-4.2.6.tgz#20341df2bdcc394f2d825d8651cfd6e5bf2d3b32" integrity sha512-4nebF2ZJGzQk0ka0O6+FZUWceyFv4vWq/0dXBMmrSeAwzOuOd/GxE5Pa64d/ndeNLG73dXoBsRzvtsVsYUv6Uw== +"@types/webfontloader@1.6.38": + version "1.6.38" + resolved "https://registry.yarnpkg.com/@types/webfontloader/-/webfontloader-1.6.38.tgz#2cb965a7e7e4a83af6bac67688bc7d1c076107e8" + integrity sha512-kUaF72Fv202suFx6yBrwXqeVRMx7hGtJTesyESZgn9sEPCUeDXm2p0SiyS1MTqW74nQP4p7JyrOCwZ7pNFns4w== + "@types/ws@^8.0.0": version "8.5.10" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.10.tgz#4acfb517970853fa6574a3a6886791d04a396787" @@ -7804,6 +7809,11 @@ webcrypto-core@^1.7.9: pvtsutils "^1.3.5" tslib "^2.6.2" +webfontloader@1.6.28: + version "1.6.28" + resolved "https://registry.yarnpkg.com/webfontloader/-/webfontloader-1.6.28.tgz#db786129253cb6e8eae54c2fb05f870af6675bae" + integrity sha512-Egb0oFEga6f+nSgasH3E0M405Pzn6y3/9tOVanv/DLfa1YBIgcv90L18YyWnvXkRbIM17v5Kv6IT2N6g1x5tvQ== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"