diff --git a/src/localization/en.json b/src/localization/en.json index f4c0b083..718565af 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -703,7 +703,7 @@ "bio_language": "Target Language", "theme_mode": "Theme", "font_family": "Font", - "font_family_tooltip": "Only the system font affects CJK characters", + "cjk_font_pack": "CJK Font", "font_family_inter": "Inter", "font_family_noto_sans": "Noto Sans", "font_family_source_sans_3": "Source Sans 3", @@ -713,6 +713,12 @@ "font_family_roboto": "Roboto", "font_family_fantasque_sans_mono": "Fantasque Sans Mono", "font_family_system_ui": "System Font", + "font_family_custom": "Custom", + "font_family_custom_dialog_title": "Custom Font Family", + "font_family_custom_dialog_description": "Enter a valid CSS font-family value", + "font_family_custom_invalid": "Invalid font-family. Use comma-separated font names, e.g. 'My Font', Arial, sans-serif", + "cjk_font_pack_noto": "Noto Sans CJK", + "cjk_font_pack_pht": "PuHuiTi CJK", "theme_mode_system": "System", "theme_mode_light": "Light", "theme_mode_dark": "Dark", diff --git a/src/localization/ja.json b/src/localization/ja.json index fdd6c2ea..66451e9c 100644 --- a/src/localization/ja.json +++ b/src/localization/ja.json @@ -564,7 +564,6 @@ "bio_language": "翻訳先の言語", "theme_mode": "テーマ", "font_family": "フォント", - "font_family_tooltip": "システムフォントのみがCJK文字に影響します", "font_family_inter": "Inter", "font_family_noto_sans": "Noto Sans", "font_family_source_sans_3": "Source Sans 3", diff --git a/src/localization/zh-CN.json b/src/localization/zh-CN.json index b7a728a3..2a3edc9e 100644 --- a/src/localization/zh-CN.json +++ b/src/localization/zh-CN.json @@ -563,7 +563,6 @@ "bio_language": "目标语言", "theme_mode": "主题", "font_family": "字体", - "font_family_tooltip": "只有系统字体会影响 CJK (也就是中/日/韩文)字符。此外,由于部分字体使用了 fonts.gstatic.com 源,所以在网络受限地区可能需要魔法", "font_family_inter": "Inter", "font_family_noto_sans": "Noto Sans", "font_family_source_sans_3": "Source Sans 3", diff --git a/src/localization/zh-TW.json b/src/localization/zh-TW.json index 9a4fad61..31302401 100644 --- a/src/localization/zh-TW.json +++ b/src/localization/zh-TW.json @@ -559,7 +559,6 @@ "bio_language": "目標語言", "theme_mode": "主題", "font_family": "字型", - "font_family_tooltip": "只有系統字型會影響 CJK(中日韓)字元顯示", "font_family_inter": "Inter", "font_family_noto_sans": "Noto Sans", "font_family_source_sans_3": "Source Sans 3", diff --git a/src/plugins/index.js b/src/plugins/index.js index 7b8f9258..debc5b4d 100644 --- a/src/plugins/index.js +++ b/src/plugins/index.js @@ -1,7 +1,7 @@ import { initDayjs } from './dayjs'; import { initInteropApi } from './interopApi'; import { initNoty } from './noty'; -import { initUi } from './ui'; +import { initUi, initUiForVrOverlay } from './ui'; /** * @param {boolean} isVrOverlay @@ -11,6 +11,8 @@ export async function initPlugins(isVrOverlay = false) { await initInteropApi(isVrOverlay); if (!isVrOverlay) { await initUi(); + } else { + await initUiForVrOverlay(); } initDayjs(); if (isVrOverlay) { diff --git a/src/plugins/ui.js b/src/plugins/ui.js index 71a4b8df..c800bf2d 100644 --- a/src/plugins/ui.js +++ b/src/plugins/ui.js @@ -1,12 +1,16 @@ import { - // changeAppDarkStyle, + applyAppCjkFontPack, + applyAppFontFamily, changeAppThemeStyle, changeHtmlLangAttribute, getThemeMode, initThemeColor, refreshCustomCss - // setLoginContainerStyle } from '../shared/utils/base/ui'; +import { + APP_CJK_FONT_PACK_DEFAULT_KEY, + APP_FONT_DEFAULT_KEY +} from '../shared/constants'; import { i18n, loadLocalizedStrings } from './i18n'; import configRepository from '../services/config'; @@ -22,9 +26,7 @@ export async function initUi() { await loadLocalizedStrings(language); changeHtmlLangAttribute(language); - const { initThemeMode, isDarkMode } = - await getThemeMode(configRepository); - // setLoginContainerStyle(isDarkMode); + const { initThemeMode } = await getThemeMode(configRepository); changeAppThemeStyle(initThemeMode); await initThemeColor(); } catch (error) { @@ -33,3 +35,30 @@ export async function initUi() { refreshCustomCss(); } + +export async function initUiForVrOverlay() { + try { + const [language, fontFamily, customFontFamily, cjkFontPack] = + await Promise.all([ + configRepository.getString('VRCX_appLanguage', 'en'), + configRepository.getString( + 'VRCX_fontFamily', + APP_FONT_DEFAULT_KEY + ), + configRepository.getString('VRCX_customFontFamily', ''), + configRepository.getString( + 'VRCX_cjkFontPack', + APP_CJK_FONT_PACK_DEFAULT_KEY + ) + ]); + + // @ts-ignore + i18n.locale = language; + await loadLocalizedStrings(language); + changeHtmlLangAttribute(language); + applyAppFontFamily(fontFamily, customFontFamily); + applyAppCjkFontPack(cjkFontPack); + } catch (error) { + console.error('Error initializing VR locale and fonts:', error); + } +} diff --git a/src/shared/constants/fonts.js b/src/shared/constants/fonts.js index fcf15699..e1ffdd5a 100644 --- a/src/shared/constants/fonts.js +++ b/src/shared/constants/fonts.js @@ -1,4 +1,5 @@ const APP_FONT_DEFAULT_KEY = 'inter'; +const APP_CJK_FONT_PACK_DEFAULT_KEY = 'noto'; const APP_FONT_CONFIG = Object.freeze({ inter: { @@ -8,7 +9,7 @@ const APP_FONT_CONFIG = Object.freeze({ noto_sans: { cssName: "'Noto Sans'", cssImport: - "@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=Noto+Sans:ital,wght@0,100..900;1,100..900&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&display=swap');" + "@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap');" }, source_sans_3: { cssName: "'Source Sans 3'", @@ -18,7 +19,7 @@ const APP_FONT_CONFIG = Object.freeze({ ibm_plex_sans: { cssName: "'IBM Plex Sans'", cssImport: - "@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&display=swap');" + "@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&display=swap');" }, harmonyos_sans: { cssName: "'HarmonyOS Sans'", @@ -33,7 +34,7 @@ const APP_FONT_CONFIG = Object.freeze({ roboto: { cssName: "'Roboto'", cssImport: - "@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Roboto:ital,wght@0,100..900;1,100..900&display=swap');" + "@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap');" }, fantasque_sans_mono: { cssName: "'Fantasque Sans Mono'", @@ -43,9 +44,69 @@ const APP_FONT_CONFIG = Object.freeze({ system_ui: { cssName: 'system-ui', link: null + }, + custom: { + cssName: '', + link: null } }); const APP_FONT_FAMILIES = Object.freeze(Object.keys(APP_FONT_CONFIG)); -export { APP_FONT_CONFIG, APP_FONT_DEFAULT_KEY, APP_FONT_FAMILIES }; +const APP_CJK_FONT_PACK_CONFIG = Object.freeze({ + noto: { + cssName: Object.freeze({ + jp: "'Noto Sans JP Variable'", + kr: "'Noto Sans KR Variable'", + sc: "'Noto Sans SC Variable'", + tc: "'Noto Sans TC Variable'" + }), + link: null + }, + pht: { + cssName: Object.freeze({ + jp: "'PHT Sans JP'", + kr: "'PHT Sans KR'", + sc: "'PHT Sans SC'", + tc: "'PHT Sans TC'" + }), + cssImport: [ + '/* Simplified Chinese */', + "@font-face { font-family: 'PHT Sans SC'; src: url('https://cdn.jsdelivr.net/gh/map1en/pht@1.0.0/sc/phtsansSC-Regular.woff2') format('woff2'); font-weight: 400; font-display: swap; }", + "@font-face { font-family: 'PHT Sans SC'; src: url('https://cdn.jsdelivr.net/gh/map1en/pht@1.0.0/sc/phtsansSC-Medium.woff2') format('woff2'); font-weight: 500; font-display: swap; }", + "@font-face { font-family: 'PHT Sans SC'; src: url('https://cdn.jsdelivr.net/gh/map1en/pht@1.0.0/sc/phtsansSC-SemiBold.woff2') format('woff2'); font-weight: 600; font-display: swap; }", + "@font-face { font-family: 'PHT Sans SC'; src: url('https://cdn.jsdelivr.net/gh/map1en/pht@1.0.0/sc/phtsansSC-Bold.woff2') format('woff2'); font-weight: 700; font-display: swap; }", + '/* Traditional Chinese */', + "@font-face { font-family: 'PHT Sans TC'; src: url('https://cdn.jsdelivr.net/gh/map1en/pht@1.0.0/tc/phtsansTC-55.woff2') format('woff2'); font-weight: 400; font-display: swap; }", + "@font-face { font-family: 'PHT Sans TC'; src: url('https://cdn.jsdelivr.net/gh/map1en/pht@1.0.0/tc/phtsansTC-75.woff2') format('woff2'); font-weight: 600; font-display: swap; }", + '/* Japanese */', + "@font-face { font-family: 'PHT Sans JP'; src: url('https://cdn.jsdelivr.net/gh/map1en/pht@1.0.0/jp/phtsansJP-Regular.woff2') format('woff2'); font-weight: 400; font-display: swap; }", + "@font-face { font-family: 'PHT Sans JP'; src: url('https://cdn.jsdelivr.net/gh/map1en/pht@1.0.0/jp/phtsansJP-Medium.woff2') format('woff2'); font-weight: 500; font-display: swap; }", + "@font-face { font-family: 'PHT Sans JP'; src: url('https://cdn.jsdelivr.net/gh/map1en/pht@1.0.0/jp/phtsansJP-Bold.woff2') format('woff2'); font-weight: 700; font-display: swap; }", + '/* Korean */', + "@font-face { font-family: 'PHT Sans KR'; src: url('https://cdn.jsdelivr.net/gh/map1en/pht@1.0.0/kr/phtsansKR-Regular.woff2') format('woff2'); font-weight: 400; font-display: swap; }", + "@font-face { font-family: 'PHT Sans KR'; src: url('https://cdn.jsdelivr.net/gh/map1en/pht@1.0.0/kr/phtsansKR-Medium.woff2') format('woff2'); font-weight: 500; font-display: swap; }", + "@font-face { font-family: 'PHT Sans KR'; src: url('https://cdn.jsdelivr.net/gh/map1en/pht@1.0.0/kr/phtsansKR-Bold.woff2') format('woff2'); font-weight: 700; font-display: swap; }" + ].join('\n') + }, + system: { + cssName: Object.freeze({ + jp: 'system-ui', + kr: 'system-ui', + sc: 'system-ui', + tc: 'system-ui' + }), + link: null + } +}); + +const APP_CJK_FONT_PACKS = Object.freeze(Object.keys(APP_CJK_FONT_PACK_CONFIG)); + +export { + APP_FONT_CONFIG, + APP_FONT_DEFAULT_KEY, + APP_FONT_FAMILIES, + APP_CJK_FONT_PACK_CONFIG, + APP_CJK_FONT_PACK_DEFAULT_KEY, + APP_CJK_FONT_PACKS +}; diff --git a/src/shared/utils/base/ui.js b/src/shared/utils/base/ui.js index e2f2b67a..beb5c910 100644 --- a/src/shared/utils/base/ui.js +++ b/src/shared/utils/base/ui.js @@ -2,6 +2,8 @@ import { ref } from 'vue'; import { toast } from 'vue-sonner'; import { + APP_CJK_FONT_PACK_CONFIG, + APP_CJK_FONT_PACK_DEFAULT_KEY, APP_FONT_CONFIG, APP_FONT_DEFAULT_KEY, THEME_COLORS, @@ -19,6 +21,7 @@ const THEME_MODE_STYLE_ID = 'app-theme-mode-style'; const DEFAULT_THEME_COLOR_KEY = 'default'; const APP_FONT_LINK_ATTR = 'data-app-font'; +const APP_CJK_FONT_PACK_LINK_ATTR = 'data-app-cjk-font-pack'; const themeColors = THEME_COLORS.map((theme) => ({ ...theme, @@ -166,44 +169,89 @@ function resolveAppFontFamily(fontKey) { }; } -function ensureAppFontLinks(fontKey) { +function ensureDynamicFontStyle(attrName, styleKey, cssImport) { const head = document.head; if (!head) { return; } - document - .querySelectorAll(`style[${APP_FONT_LINK_ATTR}]`) - .forEach((styleEl) => { - if (styleEl.getAttribute(APP_FONT_LINK_ATTR) !== fontKey) { - styleEl.remove(); - } - }); + document.querySelectorAll(`style[${attrName}]`).forEach((styleEl) => { + if (styleEl.getAttribute(attrName) !== styleKey) { + styleEl.remove(); + } + }); - const config = APP_FONT_CONFIG[fontKey]; - if (!config?.cssImport) { + if (!cssImport) { return; } - const existing = document.querySelector( - `style[${APP_FONT_LINK_ATTR}="${fontKey}"]` - ); + const existing = document.querySelector(`style[${attrName}="${styleKey}"]`); if (existing) { return; } const styleEl = document.createElement('style'); - styleEl.setAttribute(APP_FONT_LINK_ATTR, fontKey); - styleEl.textContent = config.cssImport; + styleEl.setAttribute(attrName, styleKey); + styleEl.textContent = cssImport; head.appendChild(styleEl); } -function applyAppFontFamily(fontKey) { +function resolveAppCjkFontPack(packKey) { + const normalized = String(packKey || '') + .trim() + .toLowerCase(); + if (APP_CJK_FONT_PACK_CONFIG[normalized]) { + return { key: normalized, ...APP_CJK_FONT_PACK_CONFIG[normalized] }; + } + return { + key: APP_CJK_FONT_PACK_DEFAULT_KEY, + ...APP_CJK_FONT_PACK_CONFIG[APP_CJK_FONT_PACK_DEFAULT_KEY] + }; +} + +function ensureAppCjkFontPackLinks(packKey) { + const config = APP_CJK_FONT_PACK_CONFIG[packKey]; + ensureDynamicFontStyle( + APP_CJK_FONT_PACK_LINK_ATTR, + packKey, + config?.cssImport + ); +} + +function applyAppFontFamily(fontKey, customCssName) { + if (fontKey === 'custom') { + const cssName = String(customCssName || '').trim() || 'system-ui'; + const root = document.documentElement; + root.style.setProperty('--font-western-primary', cssName); + ensureDynamicFontStyle(APP_FONT_LINK_ATTR, 'custom', null); + return { + key: 'custom', + ...APP_FONT_CONFIG.custom, + cssName + }; + } + const resolved = resolveAppFontFamily(fontKey); const root = document.documentElement; root.style.setProperty('--font-western-primary', resolved.cssName); + ensureDynamicFontStyle( + APP_FONT_LINK_ATTR, + resolved.key, + resolved.cssImport + ); - ensureAppFontLinks(resolved.key); + return resolved; +} + +function applyAppCjkFontPack(packKey) { + const resolved = resolveAppCjkFontPack(packKey); + const root = document.documentElement; + root.style.setProperty('--font-cjk-jp-primary', resolved.cssName.jp); + root.style.setProperty('--font-cjk-sc-primary', resolved.cssName.sc); + root.style.setProperty('--font-cjk-kr-primary', resolved.cssName.kr); + root.style.setProperty('--font-cjk-tc-primary', resolved.cssName.tc); + + ensureAppCjkFontPackLinks(resolved.key); return resolved; } @@ -461,6 +509,7 @@ export { refreshCustomCss, refreshCustomScript, applyAppFontFamily, + applyAppCjkFontPack, HueToHex, HSVtoRGB, formatJsonVars, diff --git a/src/stores/settings/appearance.js b/src/stores/settings/appearance.js index a9c1ee6d..d238a50f 100644 --- a/src/stores/settings/appearance.js +++ b/src/stores/settings/appearance.js @@ -4,6 +4,8 @@ import { useI18n } from 'vue-i18n'; import { useRouter } from 'vue-router'; import { + APP_CJK_FONT_PACK_DEFAULT_KEY, + APP_CJK_FONT_PACKS, APP_FONT_DEFAULT_KEY, APP_FONT_FAMILIES, SEARCH_LIMIT_MAX, @@ -13,6 +15,7 @@ import { THEME_CONFIG } from '../../shared/constants'; import { + applyAppCjkFontPack, HueToHex, applyAppFontFamily, changeAppThemeStyle, @@ -55,6 +58,8 @@ export const useAppearanceSettingsStore = defineStore( const isDarkMode = ref(false); const lastDarkTheme = ref('dark'); const appFontFamily = ref('inter'); + const customFontFamily = ref(''); + const appCjkFontPack = ref(APP_CJK_FONT_PACK_DEFAULT_KEY); const displayVRCPlusIconsAsAvatar = ref(false); const hideNicknames = ref(false); const showInstanceIdInLocation = ref(false); @@ -168,6 +173,8 @@ export const useAppearanceSettingsStore = defineStore( dataTableStripedConfig, showPointerOnHoverConfig, appFontFamilyConfig, + customFontFamilyConfig, + appCjkFontPackConfig, lastDarkThemeConfig ] = await Promise.all([ configRepository.getString('VRCX_appLanguage'), @@ -234,6 +241,11 @@ export const useAppearanceSettingsStore = defineStore( 'VRCX_fontFamily', APP_FONT_DEFAULT_KEY ), + configRepository.getString('VRCX_customFontFamily', ''), + configRepository.getString( + 'VRCX_cjkFontPack', + APP_CJK_FONT_PACK_DEFAULT_KEY + ), configRepository.getString( 'VRCX_lastDarkTheme', fallbackDarkTheme @@ -262,7 +274,11 @@ export const useAppearanceSettingsStore = defineStore( fallbackDarkTheme ); appFontFamily.value = normalizeAppFontFamily(appFontFamilyConfig); - applyAppFontFamily(appFontFamily.value); + customFontFamily.value = customFontFamilyConfig || ''; + appCjkFontPack.value = + normalizeAppCjkFontPack(appCjkFontPackConfig); + applyAppFontFamily(appFontFamily.value, customFontFamily.value); + applyAppCjkFontPack(appCjkFontPack.value); displayVRCPlusIconsAsAvatar.value = displayVRCPlusIconsAsAvatarConfig; @@ -508,11 +524,22 @@ export const useAppearanceSettingsStore = defineStore( * @param value */ function normalizeAppFontFamily(value) { + if (value === 'custom') return 'custom'; return APP_FONT_FAMILIES.includes(value) ? value : APP_FONT_DEFAULT_KEY; } + /** + * + * @param value + */ + function normalizeAppCjkFontPack(value) { + return APP_CJK_FONT_PACKS.includes(value) + ? value + : APP_CJK_FONT_PACK_DEFAULT_KEY; + } + /** * * @param value @@ -521,7 +548,26 @@ export const useAppearanceSettingsStore = defineStore( const normalized = normalizeAppFontFamily(value); appFontFamily.value = normalized; configRepository.setString('VRCX_fontFamily', normalized); - applyAppFontFamily(normalized); + applyAppFontFamily(normalized, customFontFamily.value); + } + + function setCustomFontFamily(value) { + customFontFamily.value = value; + configRepository.setString('VRCX_customFontFamily', value); + if (appFontFamily.value === 'custom') { + applyAppFontFamily('custom', value); + } + } + + /** + * + * @param value + */ + function setAppCjkFontPack(value) { + const normalized = normalizeAppCjkFontPack(value); + appCjkFontPack.value = normalized; + configRepository.setString('VRCX_cjkFontPack', normalized); + applyAppCjkFontPack(normalized); } /** @@ -1062,6 +1108,7 @@ export const useAppearanceSettingsStore = defineStore( themeMode, isDarkMode, appFontFamily, + appCjkFontPack, displayVRCPlusIconsAsAvatar, hideNicknames, showInstanceIdInLocation, @@ -1142,6 +1189,9 @@ export const useAppearanceSettingsStore = defineStore( setNavCollapsed, toggleNavCollapsed, setAppFontFamily, + customFontFamily, + setCustomFontFamily, + setAppCjkFontPack, setThemeMode, toggleThemeMode }; diff --git a/src/styles/fonts.css b/src/styles/fonts.css index a3d5b71f..ddbb2f25 100644 --- a/src/styles/fonts.css +++ b/src/styles/fonts.css @@ -12,37 +12,42 @@ } :root { + /* Keep these bootstrap defaults aligned with src/shared/constants/fonts.js */ --font-western-primary: 'Inter Variable'; + --font-cjk-jp-primary: 'Noto Sans JP Variable'; + --font-cjk-sc-primary: 'Noto Sans SC Variable'; + --font-cjk-kr-primary: 'Noto Sans KR Variable'; + --font-cjk-tc-primary: 'Noto Sans TC Variable'; --font-western: 'ellipsis-font', -apple-system, var(--font-western-primary), 'Segoe UI', - 'Roboto', 'Ubuntu', 'Cantarell', 'DejaVu Sans', sans-serif; + 'Roboto', 'Ubuntu', 'Cantarell', 'DejaVu Sans'; --font-symbol: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; --font-fallback-cjk: sans-serif; --font-primary-cjk: - 'Noto Sans JP Variable', 'Noto Sans SC Variable', - 'Noto Sans KR Variable', 'Noto Sans TC Variable'; + var(--font-cjk-jp-primary), var(--font-cjk-sc-primary), + var(--font-cjk-kr-primary), var(--font-cjk-tc-primary); } :root[lang='zh-CN'] { --font-primary-cjk: - 'Noto Sans SC Variable', 'Noto Sans JP Variable', - 'Noto Sans KR Variable', 'Noto Sans TC Variable'; + var(--font-cjk-sc-primary), var(--font-cjk-jp-primary), + var(--font-cjk-kr-primary), var(--font-cjk-tc-primary); } :root[lang='ja'] { --font-primary-cjk: - 'Noto Sans JP Variable', 'Noto Sans KR Variable', - 'Noto Sans TC Variable', 'Noto Sans SC Variable'; + var(--font-cjk-jp-primary), var(--font-cjk-kr-primary), + var(--font-cjk-tc-primary), var(--font-cjk-sc-primary); } :root[lang='ko'] { --font-primary-cjk: - 'Noto Sans KR Variable', 'Noto Sans JP Variable', - 'Noto Sans TC Variable', 'Noto Sans SC Variable'; + var(--font-cjk-kr-primary), var(--font-cjk-jp-primary), + var(--font-cjk-tc-primary), var(--font-cjk-sc-primary); } :root[lang='zh-TW'] { --font-primary-cjk: - 'Noto Sans TC Variable', 'Noto Sans JP Variable', - 'Noto Sans KR Variable', 'Noto Sans SC Variable'; + var(--font-cjk-tc-primary), var(--font-cjk-jp-primary), + var(--font-cjk-kr-primary), var(--font-cjk-sc-primary); } diff --git a/src/views/Settings/components/Tabs/AppearanceTab.vue b/src/views/Settings/components/Tabs/AppearanceTab.vue index 601ab567..092114b1 100644 --- a/src/views/Settings/components/Tabs/AppearanceTab.vue +++ b/src/views/Settings/components/Tabs/AppearanceTab.vue @@ -19,32 +19,61 @@