feat(fonts): 前端字体工具函数、主题字体状态与设置类型
This commit is contained in:
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
|
||||
}
|
||||
@@ -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]
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user