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