feat(fonts): 前端字体工具函数、主题字体状态与设置类型

This commit is contained in:
AxTps committed 2026-10-02 21:57:24 +08:00
1 parent b57219f348
commit f490600ee2
4 files changed
+240

No files matched your search

@@ -0,0 +1,179 @@
/**
* Font family plumbing shared by the appearance settings pickers and the theme
* store. Kept free of imports so `node --test` can load it directly.
*/
/** Mirrors `--font-standard` in `packages/assets/styles/defaults.scss`. */
export const DEFAULT_UI_FONT_STACK =
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif'
/** Mirrors `--mono-font` in `packages/assets/styles/defaults.scss`. */
export const DEFAULT_MONO_FONT_STACK =
'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace'
/** Picker value for "follow the launcher default"; persisted as `null`. */
export const FOLLOW_DEFAULT_FONT = ''
export interface SystemFontFamily {
family: string
monospaced: boolean
}
export interface FontFamilyOption {
value: string
label: string
subLabel?: string
searchTerms?: string[]
}
export type FontFamilyOptionOrDivider = FontFamilyOption | { type: 'divider' }
export interface FontFamilyOptionsConfig {
defaultLabel: string
selected?: string | null
missingLabel?: string
monospaceLabel?: string
groupMonospaced?: boolean
}
const MAX_FONT_FAMILY_LENGTH = 128
/**
* Quotes and escapes an installed family name so it cannot break out of the
* font-family declaration it is spliced into. Returns null when the name has
* nothing usable left.
*/
export function cssFontFamilyName(name: string): string | null {
const trimmed = name.replace(/\p{Cc}/gu, '').trim()
if (!trimmed || trimmed.length > MAX_FONT_FAMILY_LENGTH) {
return null
}
return `"${trimmed.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
/**
* The value written into `--font-standard` / `--mono-font`. The launcher stack
* always trails the chosen family so an unresolvable name degrades to the
* previous default instead of the user agent's own font.
*/
export function resolveFontFamily(
setting: string | null | undefined,
fallbackStack: string,
): string {
if (!setting) {
return fallbackStack
}
const family = cssFontFamilyName(setting)
return family ? `${family}, ${fallbackStack}` : fallbackStack
}
export function fontSettingToOptionValue(setting: string | null | undefined): string {
return setting ?? FOLLOW_DEFAULT_FONT
}
export function optionValueToFontSetting(value: string): string | null {
const trimmed = value.trim()
return trimmed === FOLLOW_DEFAULT_FONT ? null : trimmed
}
function compareFamilies(first: SystemFontFamily, second: SystemFontFamily): number {
const left = first.family.toLowerCase()
const right = second.family.toLowerCase()
if (left === right) {
return 0
}
return left < right ? -1 : 1
}
function toOption(family: SystemFontFamily, monospaceLabel?: string): FontFamilyOption {
return {
value: family.family,
label: family.family,
...(family.monospaced && monospaceLabel
? { subLabel: monospaceLabel, searchTerms: [monospaceLabel] }
: {}),
}
}
/**
* The spelling the font list uses for a stored family. Font names are matched
* case-insensitively so a stored value that only differs in case still selects
* its option instead of leaving the picker blank.
*/
export function canonicalFontFamily(
fonts: SystemFontFamily[],
setting: string | null | undefined,
): string {
const selected = optionValueToFontSetting(setting ?? '')
if (!selected) {
return FOLLOW_DEFAULT_FONT
}
const listed = fonts.find((font) => font.family.trim().toLowerCase() === selected.toLowerCase())
return listed ? listed.family.trim() : selected
}
/**
* Builds the picker options for the installed font collection: the default
* entry first, the current selection re-added when it is no longer installed,
* and — for the monospace picker — monospaced families ahead of the rest.
*/
export function buildFontFamilyOptions(
fonts: SystemFontFamily[],
config: FontFamilyOptionsConfig,
): FontFamilyOptionOrDivider[] {
const families = new Map<string, SystemFontFamily>()
for (const font of fonts) {
const family = font.family.trim()
if (!family) {
continue
}
const key = family.toLowerCase()
const known = families.get(key)
if (known) {
known.monospaced ||= font.monospaced
} else {
families.set(key, { family, monospaced: font.monospaced })
}
}
const sorted = [...families.values()].sort(compareFamilies)
const selected = optionValueToFontSetting(config.selected ?? '')
const options: FontFamilyOptionOrDivider[] = [
{ value: FOLLOW_DEFAULT_FONT, label: config.defaultLabel },
]
if (selected && !families.has(selected.toLowerCase()) && config.missingLabel) {
options.push({ value: selected, label: selected, subLabel: config.missingLabel })
}
if (config.groupMonospaced) {
const monospaced = sorted.filter((font) => font.monospaced)
const proportional = sorted.filter((font) => !font.monospaced)
options.push(...monospaced.map((font) => toOption(font, config.monospaceLabel)))
if (monospaced.length > 0 && proportional.length > 0) {
options.push({ type: 'divider' })
}
options.push(...proportional.map((font) => toOption(font, config.monospaceLabel)))
return options
}
options.push(...sorted.map((font) => toOption(font)))
return options
}
@@ -174,6 +174,8 @@ export type AppSettings = {
minimal_home_instance_id: string | null
close_behavior: 'ask' | 'close' | 'lightweight'
ui_scale: number
ui_font: string | null
mono_font: string | null
home_widgets: HomeDashboardConfig | null
terracotta_public_nodes: string[]
@@ -216,6 +218,8 @@ type LegacyMirrorSettings = {
function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings): AppSettings {
settings.close_behavior ??= 'ask'
settings.ui_scale ??= 100
settings.ui_font ??= null
settings.mono_font ??= null
const hasLegacySettings =
typeof settings.use_minecraft_mirror === 'boolean' &&
typeof settings.use_modrinth_mirror === 'boolean' &&
@@ -0,0 +1,19 @@
import { invoke } from '@tauri-apps/api/core'
import type { SystemFontFamily } from './font-family.ts'
let cached: Promise<SystemFontFamily[]> | null = null
/**
* Installed families are enumerated by the host once per session; installing a
* font while the launcher runs shows up after a restart.
*/
export function getSystemFontFamilies(): Promise<SystemFontFamily[]> {
cached ??= invoke<SystemFontFamily[]>('plugin:fonts|fonts_get_system_fonts').catch((error) => {
// A failed scan must not poison the session for a later retry.
cached = null
throw error
})
return cached
}
+38
View File
@@ -1,5 +1,11 @@
import { defineStore } from 'pinia'
import {
DEFAULT_MONO_FONT_STACK,
DEFAULT_UI_FONT_STACK,
resolveFontFamily,
} from '@/helpers/font-family.ts'
let systemThemeMq: MediaQueryList | null = null
export const DEFAULT_FEATURE_FLAGS = {
@@ -130,6 +136,9 @@ export type ThemeStore = {
devMode: boolean
featureFlags: FeatureFlags
uiFont: string | null
monoFont: string | null
}
export const DEFAULT_THEME_STORE: ThemeStore = {
@@ -154,6 +163,9 @@ export const DEFAULT_THEME_STORE: ThemeStore = {
devMode: false,
featureFlags: DEFAULT_FEATURE_FLAGS,
uiFont: null,
monoFont: null,
}
export const useTheming = defineStore('themeStore', {
@@ -249,6 +261,32 @@ export const useTheming = defineStore('themeStore', {
`${Math.min(Math.max(this.transparentBackgroundOpacity, 0), 100)}%`,
)
},
/**
* The font tokens are declared on `body` in the shared defaults, so the
* launcher picks up the chosen family by overriding the CSS variable there.
*/
setUiFont() {
const body = document.body
if (this.uiFont) {
body.style.setProperty(
'--font-standard',
resolveFontFamily(this.uiFont, DEFAULT_UI_FONT_STACK),
)
} else {
body.style.removeProperty('--font-standard')
}
},
setMonoFont() {
const body = document.body
if (this.monoFont) {
body.style.setProperty(
'--mono-font',
resolveFontFamily(this.monoFont, DEFAULT_MONO_FONT_STACK),
)
} else {
body.style.removeProperty('--mono-font')
}
},
getFeatureFlag(key: FeatureFlag) {
return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
},