[next]feat: Shuishouji and JSON Workbench need to support font switching functionality.

This commit is contained in:
RememBerBer
2026-08-18 17:06:52 +08:00
parent 3d84b0570d
commit d79f8059f5
3 changed files with 165 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
import { useMemo } from 'react'
import { cssFontFamily, fontSelectOptions, useSystemFontFamilies } from '@/shared/fonts/systemFonts'
type FontSelectProps = {
value: string
ariaLabel: string
disabled?: boolean
className?: string
emptyLabel?: string
labels?: Record<string, string>
onChange: (value: string) => void
}
export function FontSelect({
value,
ariaLabel,
disabled = false,
className = '',
emptyLabel,
labels = {},
onChange
}: FontSelectProps) {
const fonts = useSystemFontFamilies()
const options = useMemo(() => fontSelectOptions(fonts, value), [fonts, value])
return (
<select
className={className}
aria-label={ariaLabel}
title={ariaLabel}
disabled={disabled}
value={value}
onChange={(event) => onChange(event.target.value)}
>
{emptyLabel !== undefined ? <option value="">{emptyLabel}</option> : null}
{options.map((font) => (
<option key={font} value={font} style={{ fontFamily: cssFontFamily(font) }}>
{labels[font] ?? font}
</option>
))}
</select>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest'
import { cssFontFamily, fallbackEditorFonts, fontSelectOptions, listSystemFontFamilies } from './systemFonts'
describe('cssFontFamily', () => {
it('uses the app UI stack for empty and system-ui values', () => {
expect(cssFontFamily('')).toBe('var(--app-font-family), system-ui, sans-serif')
expect(cssFontFamily('system-ui')).toBe('var(--app-font-family), system-ui, sans-serif')
})
it('keeps a monospace fallback stack for ui-monospace', () => {
expect(cssFontFamily('ui-monospace')).toBe('ui-monospace, SFMono-Regular, Menlo, Consolas, monospace')
})
it('quotes font names that need CSS escaping', () => {
expect(cssFontFamily('PingFang SC')).toBe('"PingFang SC", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace')
expect(cssFontFamily('Font "Demo"')).toBe('"Font \\"Demo\\"", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace')
})
})
describe('fontSelectOptions', () => {
it('keeps ui-monospace first and includes the current font', () => {
expect(fontSelectOptions(['Georgia', 'ui-monospace', 'Arial'], 'Comic Sans MS')).toEqual([
'ui-monospace',
'Arial',
'Comic Sans MS',
'Georgia'
])
})
})
describe('listSystemFontFamilies', () => {
it('merges queryLocalFonts families with the fallback list', async () => {
vi.stubGlobal('queryLocalFonts', async () => [{ family: ' Custom Editor Font ' }, { family: '' }])
try {
const fonts = await listSystemFontFamilies()
expect(fonts).toContain('Custom Editor Font')
expect(fonts).toEqual(expect.arrayContaining(fallbackEditorFonts))
} finally {
vi.unstubAllGlobals()
}
})
})
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react'
export const fallbackEditorFonts = [
'ui-monospace',
'Menlo',
'Monaco',
'Consolas',
'Courier New',
'JetBrains Mono',
'PingFang SC',
'Hiragino Sans GB',
'Microsoft YaHei',
'等线',
'Songti SC',
'SimSun',
'Georgia',
'Times New Roman',
'SF Pro Text',
'Helvetica Neue',
'Arial',
'system-ui'
]
type LocalFontData = {
family: string
}
export async function listSystemFontFamilies(): Promise<string[]> {
const families = new Set(fallbackEditorFonts)
try {
for (const font of await queryInstalledFonts()) {
const family = font.family.trim()
if (family) families.add(family)
}
} catch {
// Local Font Access may be unavailable in tests or locked-down environments.
}
return sortFontFamilies([...families])
}
export function cssFontFamily(fontName: string): string {
const value = fontName.trim()
if (!value || value === 'system-ui') return 'var(--app-font-family), system-ui, sans-serif'
if (value === 'ui-monospace') return 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
const quoted = /[\s,"'\\]/.test(value) ? `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` : value
return `${quoted}, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`
}
export function fontSelectOptions(fonts: string[], current = ''): string[] {
const unique = new Set(fonts.filter(Boolean))
if (current.trim()) unique.add(current.trim())
const rest = sortFontFamilies([...unique].filter((font) => font !== 'ui-monospace'))
return unique.has('ui-monospace') ? ['ui-monospace', ...rest] : rest
}
export function useSystemFontFamilies(): string[] {
const [fonts, setFonts] = useState(fallbackEditorFonts)
useEffect(() => {
let cancelled = false
void listSystemFontFamilies().then((next) => {
if (!cancelled) setFonts(next)
})
return () => {
cancelled = true
}
}, [])
return fonts
}
function sortFontFamilies(fonts: string[]): string[] {
return [...fonts].sort((left, right) => left.localeCompare(right, undefined, { sensitivity: 'base' }))
}
async function queryInstalledFonts(): Promise<LocalFontData[]> {
const query = (globalThis as { queryLocalFonts?: () => Promise<LocalFontData[]> }).queryLocalFonts
if (typeof query !== 'function') return []
return query()
}