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
|
minimal_home_instance_id: string | null
|
||||||
close_behavior: 'ask' | 'close' | 'lightweight'
|
close_behavior: 'ask' | 'close' | 'lightweight'
|
||||||
ui_scale: number
|
ui_scale: number
|
||||||
|
ui_font: string | null
|
||||||
|
mono_font: string | null
|
||||||
home_widgets: HomeDashboardConfig | null
|
home_widgets: HomeDashboardConfig | null
|
||||||
terracotta_public_nodes: string[]
|
terracotta_public_nodes: string[]
|
||||||
|
|
||||||
@@ -216,6 +218,8 @@ type LegacyMirrorSettings = {
|
|||||||
function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings): AppSettings {
|
function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings): AppSettings {
|
||||||
settings.close_behavior ??= 'ask'
|
settings.close_behavior ??= 'ask'
|
||||||
settings.ui_scale ??= 100
|
settings.ui_scale ??= 100
|
||||||
|
settings.ui_font ??= null
|
||||||
|
settings.mono_font ??= null
|
||||||
const hasLegacySettings =
|
const hasLegacySettings =
|
||||||
typeof settings.use_minecraft_mirror === 'boolean' &&
|
typeof settings.use_minecraft_mirror === 'boolean' &&
|
||||||
typeof settings.use_modrinth_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 { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_MONO_FONT_STACK,
|
||||||
|
DEFAULT_UI_FONT_STACK,
|
||||||
|
resolveFontFamily,
|
||||||
|
} from '@/helpers/font-family.ts'
|
||||||
|
|
||||||
let systemThemeMq: MediaQueryList | null = null
|
let systemThemeMq: MediaQueryList | null = null
|
||||||
|
|
||||||
export const DEFAULT_FEATURE_FLAGS = {
|
export const DEFAULT_FEATURE_FLAGS = {
|
||||||
@@ -130,6 +136,9 @@ export type ThemeStore = {
|
|||||||
|
|
||||||
devMode: boolean
|
devMode: boolean
|
||||||
featureFlags: FeatureFlags
|
featureFlags: FeatureFlags
|
||||||
|
|
||||||
|
uiFont: string | null
|
||||||
|
monoFont: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_THEME_STORE: ThemeStore = {
|
export const DEFAULT_THEME_STORE: ThemeStore = {
|
||||||
@@ -154,6 +163,9 @@ export const DEFAULT_THEME_STORE: ThemeStore = {
|
|||||||
|
|
||||||
devMode: false,
|
devMode: false,
|
||||||
featureFlags: DEFAULT_FEATURE_FLAGS,
|
featureFlags: DEFAULT_FEATURE_FLAGS,
|
||||||
|
|
||||||
|
uiFont: null,
|
||||||
|
monoFont: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTheming = defineStore('themeStore', {
|
export const useTheming = defineStore('themeStore', {
|
||||||
@@ -249,6 +261,32 @@ export const useTheming = defineStore('themeStore', {
|
|||||||
`${Math.min(Math.max(this.transparentBackgroundOpacity, 0), 100)}%`,
|
`${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) {
|
getFeatureFlag(key: FeatureFlag) {
|
||||||
return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
|
return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user