返回 DeepSeek-Reasonix
typographyPreferences.ts
根目录 / desktop / frontend / src / lib / typographyPreferences.ts
1 export const TYPOGRAPHY_REGIONS = ["interface", "conversation", "composer", "code", "metadata"] as const;
2
3 export type TypographyRegion = (typeof TYPOGRAPHY_REGIONS)[number];
4
5 export const REGION_FONT_FAMILIES = [
6 "inherit",
7 "system",
8 "yahei",
9 "pingfang",
10 "noto",
11 "cascadia",
12 "jetbrains",
13 "sfmono",
14 "custom",
15 ] as const;
16
17 export type RegionFontFamily = (typeof REGION_FONT_FAMILIES)[number];
18
19 export type RegionTypography = {
20 followGlobal: boolean;
21 fontFamily: RegionFontFamily;
22 customFontName: string;
23 fontSize: number;
24 };
25
26 export type TypographyPreferences = Record<TypographyRegion, RegionTypography>;
27
28 export const TYPOGRAPHY_STORAGE_KEY = "reasonix-region-typography-v1";
29
30 export const TYPOGRAPHY_REGION_META: Record<TypographyRegion, { baseSize: number; min: number; max: number }> = {
31 interface: { baseSize: 14, min: 11, max: 20 },
32 conversation: { baseSize: 14, min: 12, max: 24 },
33 composer: { baseSize: 14, min: 12, max: 24 },
34 code: { baseSize: 12, min: 10, max: 22 },
35 metadata: { baseSize: 12, min: 9, max: 18 },
36 };
37
38 const FONT_STACKS: Record<RegionFontFamily, string> = {
39 inherit: "",
40 system: 'var(--font-ui)',
41 yahei: '"Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", "PingFang SC", sans-serif',
42 pingfang: '"PingFang SC", "苹方-简", "Noto Sans SC", "Microsoft YaHei", sans-serif',
43 noto: '"Noto Sans SC", "Noto Sans", "PingFang SC", "Microsoft YaHei", sans-serif',
44 cascadia: '"Cascadia Code", "Cascadia Mono", Consolas, ui-monospace, monospace',
45 jetbrains: '"JetBrains Mono", "Cascadia Code", "SF Mono", Consolas, ui-monospace, monospace',
46 sfmono: '"SF Mono", SFMono-Regular, ui-monospace, Menlo, Monaco, monospace',
47 custom: "",
48 };
49
50 function defaultRegion(region: TypographyRegion): RegionTypography {
51 return {
52 followGlobal: true,
53 fontFamily: "inherit",
54 customFontName: "",
55 fontSize: TYPOGRAPHY_REGION_META[region].baseSize,
56 };
57 }
58
59 export function createDefaultTypographyPreferences(): TypographyPreferences {
60 return Object.fromEntries(TYPOGRAPHY_REGIONS.map((region) => [region, defaultRegion(region)])) as TypographyPreferences;
61 }
62
63 export function sanitizeCustomFontName(value: unknown): string {
64 if (typeof value !== "string") return "";
65 const compact = value.trim().replace(/\s+/g, " ").slice(0, 200);
66 return isSafeCustomFontNameInput(compact) ? compact : "";
67 }
68
69 export function isSafeCustomFontNameInput(value: unknown): value is string {
70 return typeof value === "string" && !/[;{}<>]/.test(value);
71 }
72
73 export function normalizeTypographyPreferences(value: unknown): TypographyPreferences {
74 const defaults = createDefaultTypographyPreferences();
75 const source = value && typeof value === "object" ? (value as Record<string, unknown>) : {};
76
77 for (const region of TYPOGRAPHY_REGIONS) {
78 const raw = source[region];
79 if (!raw || typeof raw !== "object") continue;
80 const candidate = raw as Record<string, unknown>;
81 const meta = TYPOGRAPHY_REGION_META[region];
82 const numericSize = typeof candidate.fontSize === "number" && Number.isFinite(candidate.fontSize)
83 ? candidate.fontSize
84 : meta.baseSize;
85 defaults[region] = {
86 followGlobal: candidate.followGlobal !== false,
87 fontFamily: typeof candidate.fontFamily === "string" && (REGION_FONT_FAMILIES as readonly string[]).includes(candidate.fontFamily)
88 ? candidate.fontFamily as RegionFontFamily
89 : "inherit",
90 customFontName: sanitizeCustomFontName(candidate.customFontName),
91 fontSize: Math.round(Math.min(meta.max, Math.max(meta.min, numericSize))),
92 };
93 }
94 return defaults;
95 }
96
97 export function getTypographyPreferences(): TypographyPreferences {
98 if (typeof localStorage === "undefined") return createDefaultTypographyPreferences();
99 try {
100 const stored = localStorage.getItem(TYPOGRAPHY_STORAGE_KEY);
101 return stored ? normalizeTypographyPreferences(JSON.parse(stored)) : createDefaultTypographyPreferences();
102 } catch {
103 return createDefaultTypographyPreferences();
104 }
105 }
106
107 export function fontStackForPreference(preference: RegionTypography): string {
108 if (preference.fontFamily === "custom") return sanitizeCustomFontName(preference.customFontName);
109 return FONT_STACKS[preference.fontFamily];
110 }
111
112 export function applyTypographyPreferences(preferences: TypographyPreferences): void {
113 if (typeof document === "undefined") return;
114 const normalized = normalizeTypographyPreferences(preferences);
115 const root = document.documentElement;
116
117 for (const region of TYPOGRAPHY_REGIONS) {
118 const preference = normalized[region];
119 const scaleProperty = `--typography-${region}-scale`;
120 const sizeProperty = `--typography-${region}-size`;
121 const fontProperty = `--typography-${region}-font`;
122 if (preference.followGlobal) {
123 root.style.removeProperty(scaleProperty);
124 root.style.removeProperty(sizeProperty);
125 root.style.removeProperty(fontProperty);
126 continue;
127 }
128 root.style.setProperty(scaleProperty, String(preference.fontSize / TYPOGRAPHY_REGION_META[region].baseSize));
129 root.style.setProperty(sizeProperty, `${preference.fontSize}px`);
130 const fontStack = fontStackForPreference(preference);
131 if (fontStack) root.style.setProperty(fontProperty, fontStack);
132 else root.style.removeProperty(fontProperty);
133 }
134
135 try {
136 localStorage.setItem(TYPOGRAPHY_STORAGE_KEY, JSON.stringify(normalized));
137 } catch {
138 /* private mode / no storage */
139 }
140 }
141
142 export function initTypographyPreferences(): void {
143 applyTypographyPreferences(getTypographyPreferences());
144 }
145
145 lines TYPESCRIPT