返回 DeepSeek-Reasonix
TypographySettings.tsx
根目录 / desktop / frontend / src / components / TypographySettings.tsx
1 import { useLayoutEffect, useMemo, useRef, useState } from "react";
2 import { ArrowLeft, ChevronDown, ChevronRight, Minus, Plus, RotateCcw, Sparkles, Undo2, UserRound } from "lucide-react";
3 import { useT } from "../lib/i18n";
4 import {
5 TYPOGRAPHY_REGIONS,
6 TYPOGRAPHY_REGION_META,
7 applyTypographyPreferences,
8 createDefaultTypographyPreferences,
9 getTypographyPreferences,
10 isSafeCustomFontNameInput,
11 sanitizeCustomFontName,
12 type RegionFontFamily,
13 type TypographyPreferences,
14 type TypographyRegion,
15 } from "../lib/typographyPreferences";
16
17 const REGION_KEYS: Record<TypographyRegion, Parameters<ReturnType<typeof useT>>[0]> = {
18 interface: "settings.typography.region.interface",
19 conversation: "settings.typography.region.conversation",
20 composer: "settings.typography.region.composer",
21 code: "settings.typography.region.code",
22 metadata: "settings.typography.region.metadata",
23 };
24
25 const FONT_OPTIONS: Array<{ value: RegionFontFamily; key: Parameters<ReturnType<typeof useT>>[0] }> = [
26 { value: "inherit", key: "settings.typography.font.inherit" },
27 { value: "system", key: "settings.fontFamilySystem" },
28 { value: "yahei", key: "settings.fontFamilyYaHei" },
29 { value: "pingfang", key: "settings.fontFamilyPingFang" },
30 { value: "noto", key: "settings.fontFamilyNoto" },
31 { value: "cascadia", key: "settings.monoFontFamilyCascadia" },
32 { value: "jetbrains", key: "settings.monoFontFamilyJetBrains" },
33 { value: "sfmono", key: "settings.monoFontFamilySFMono" },
34 { value: "custom", key: "settings.fontFamilyCustom" },
35 ];
36
37 type PreviewTab = "body" | "reasoning" | "tools";
38
39 export function TypographySettings({ onBack }: { onBack: () => void }) {
40 const t = useT();
41 const [selected, setSelected] = useState<TypographyRegion>("conversation");
42 const [preferences, setPreferences] = useState<TypographyPreferences>(() => getTypographyPreferences());
43 const [previewTab, setPreviewTab] = useState<PreviewTab>("body");
44 const [advancedOpen, setAdvancedOpen] = useState(false);
45 const previous = useRef<TypographyPreferences | null>(null);
46 const preference = preferences[selected];
47 const meta = TYPOGRAPHY_REGION_META[selected];
48
49 useLayoutEffect(() => {
50 document.querySelector<HTMLElement>(".settings-center__content")?.scrollTo({ top: 0 });
51 }, []);
52 const presets = useMemo(
53 () => Array.from(new Set([meta.baseSize, meta.baseSize + 2, meta.baseSize + 4, meta.baseSize + 6])).filter((n) => n <= meta.max),
54 [meta],
55 );
56
57 const commit = (next: TypographyPreferences) => {
58 previous.current = preferences;
59 setPreferences(next);
60 applyTypographyPreferences(next);
61 };
62 const updateSelected = (patch: Partial<typeof preference>) => {
63 commit({ ...preferences, [selected]: { ...preference, ...patch } });
64 };
65 const setSize = (size: number) => updateSelected({
66 followGlobal: false,
67 fontSize: Math.min(meta.max, Math.max(meta.min, Math.round(size))),
68 });
69 const resetSelected = () => {
70 const defaults = createDefaultTypographyPreferences();
71 commit({ ...preferences, [selected]: defaults[selected] });
72 };
73 const resetAll = () => commit(createDefaultTypographyPreferences());
74 const undo = () => {
75 if (!previous.current) return;
76 const next = previous.current;
77 previous.current = preferences;
78 setPreferences(next);
79 applyTypographyPreferences(next);
80 };
81
82 return (
83 <div className="typography-settings">
84 <header className="typography-settings__header">
85 <div>
86 <div className="typography-settings__heading-row">
87 <button type="button" className="typography-settings__back" aria-label={t("settings.typography.back")} title={t("settings.typography.back")} onClick={onBack}>
88 <ArrowLeft size={17} aria-hidden="true" />
89 </button>
90 <h2>{t("settings.typography.title")}</h2>
91 </div>
92 <p>{t("settings.typography.subtitle")}</p>
93 </div>
94 <button type="button" className="btn btn--small" onClick={resetAll}>
95 <RotateCcw size={13} aria-hidden="true" /> {t("settings.typography.resetAll")}
96 </button>
97 </header>
98
99 <div className="typography-settings__workspace">
100 <nav className="typography-settings__regions" aria-label={t("settings.typography.regions")}>
101 <div className="typography-settings__regions-label">{t("settings.typography.regions")}</div>
102 <button type="button" className="typography-settings__region" onClick={onBack}>
103 <span>{t("settings.typography.globalDefault")}</span>
104 <span />
105 <ChevronRight size={14} aria-hidden="true" />
106 </button>
107 {TYPOGRAPHY_REGIONS.map((region) => (
108 <button
109 key={region}
110 type="button"
111 className={region === selected ? "typography-settings__region typography-settings__region--active" : "typography-settings__region"}
112 aria-current={region === selected ? "page" : undefined}
113 onClick={() => setSelected(region)}
114 >
115 <span>{t(REGION_KEYS[region])}</span>
116 {!preferences[region].followGlobal ? <span className="typography-settings__dot" aria-label={t("settings.typography.customized")} /> : null}
117 <ChevronRight size={14} aria-hidden="true" />
118 </button>
119 ))}
120 </nav>
121
122 <main className="typography-settings__detail">
123 <div className="typography-settings__detail-head">
124 <div>
125 <div className="typography-settings__title-row">
126 <h3>{t(REGION_KEYS[selected])}</h3>
127 {!preference.followGlobal ? <span className="typography-settings__badge">{t("settings.typography.customized")}</span> : null}
128 </div>
129 <p>{t(`settings.typography.desc.${selected}` as Parameters<typeof t>[0])}</p>
130 </div>
131 <label className="typography-settings__follow">
132 <span>{t("settings.typography.followGlobal")}</span>
133 <input
134 type="checkbox"
135 checked={preference.followGlobal}
136 onChange={(event) => updateSelected({ followGlobal: event.target.checked })}
137 />
138 <span className="typography-settings__switch" aria-hidden="true" />
139 </label>
140 </div>
141
142 <div className={preference.followGlobal ? "typography-settings__controls typography-settings__controls--disabled" : "typography-settings__controls"}>
143 <label className="typography-settings__field">
144 <span>{t("settings.typography.font")}</span>
145 <select
146 value={preference.fontFamily}
147 disabled={preference.followGlobal}
148 onChange={(event) => updateSelected({ fontFamily: event.target.value as RegionFontFamily })}
149 >
150 {FONT_OPTIONS.map((option) => <option key={option.value} value={option.value}>{t(option.key)}</option>)}
151 </select>
152 </label>
153 {preference.fontFamily === "custom" && !preference.followGlobal ? (
154 <label className="typography-settings__field">
155 <span>{t("settings.typography.customFont")}</span>
156 <input
157 type="text"
158 value={preference.customFontName}
159 placeholder={t("settings.fontFamilyCustomPlaceholder")}
160 onChange={(event) => {
161 const value = event.target.value.slice(0, 200);
162 if (isSafeCustomFontNameInput(value)) updateSelected({ customFontName: value });
163 }}
164 onBlur={() => {
165 const normalized = sanitizeCustomFontName(preference.customFontName);
166 if (normalized !== preference.customFontName) updateSelected({ customFontName: normalized });
167 }}
168 />
169 </label>
170 ) : null}
171
172 <div className="typography-settings__field">
173 <span>{t("settings.typography.size")}</span>
174 <div className="typography-settings__size-row">
175 <div className="typography-settings__stepper">
176 <button type="button" disabled={preference.followGlobal || preference.fontSize <= meta.min} onClick={() => setSize(preference.fontSize - 1)} aria-label={t("settings.typography.decrease")}><Minus size={14} /></button>
177 <span>{preference.fontSize}px</span>
178 <button type="button" disabled={preference.followGlobal || preference.fontSize >= meta.max} onClick={() => setSize(preference.fontSize + 1)} aria-label={t("settings.typography.increase")}><Plus size={14} /></button>
179 </div>
180 <div className="typography-settings__presets">
181 {presets.map((size) => (
182 <button key={size} type="button" disabled={preference.followGlobal} className={preference.fontSize === size ? "is-active" : ""} onClick={() => setSize(size)}>{size}</button>
183 ))}
184 </div>
185 </div>
186 </div>
187
188 <button type="button" className="typography-settings__advanced" onClick={() => setAdvancedOpen((open) => !open)} aria-expanded={advancedOpen}>
189 {advancedOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
190 <span>{t("settings.typography.advanced")}</span>
191 <small>{t("settings.typography.advancedSummary")}</small>
192 </button>
193 {advancedOpen ? <p className="typography-settings__advanced-note">{t("settings.typography.advancedNote")}</p> : null}
194 </div>
195
196 <div className="typography-settings__actions">
197 <button type="button" className="btn btn--small" onClick={resetSelected}>{t("settings.typography.resetRegion")}</button>
198 <span>{t("settings.typography.applied")}</span>
199 <button type="button" className="typography-settings__undo" disabled={!previous.current} onClick={undo}><Undo2 size={13} /> {t("settings.typography.undo")}</button>
200 </div>
201
202 <section className={`typography-settings__preview typography-settings__preview--${selected}`}>
203 <div className="typography-settings__preview-head">
204 <span>{t("settings.typography.preview")}</span>
205 <div role="tablist" aria-label={t("settings.typography.preview")}>
206 {(["body", "reasoning", "tools"] as PreviewTab[]).map((tab) => (
207 <button key={tab} type="button" role="tab" aria-selected={previewTab === tab} className={previewTab === tab ? "is-active" : ""} onClick={() => setPreviewTab(tab)}>{t(`settings.typography.preview.${tab}` as Parameters<typeof t>[0])}</button>
208 ))}
209 </div>
210 </div>
211 <div className="typography-settings__preview-body">
212 {previewTab === "body" ? (
213 <div className="typography-settings__preview-chat">
214 <div className="typography-settings__preview-message">
215 <span className="typography-settings__preview-avatar"><UserRound size={14} aria-hidden="true" /></span>
216 <p>{t("settings.typography.previewQuestion")}</p>
217 </div>
218 <div className="typography-settings__preview-message typography-settings__preview-message--assistant">
219 <span className="typography-settings__preview-avatar"><Sparkles size={14} aria-hidden="true" /></span>
220 <div>
221 <p>{t("settings.typography.previewBody")}</p>
222 <code>E = hνF|k|</code>
223 <small>{t("settings.typography.previewSources")}</small>
224 </div>
225 </div>
226 </div>
227 ) : null}
228 {previewTab === "reasoning" ? <p className="typography-settings__preview-reasoning">{t("settings.typography.previewReasoning")}</p> : null}
229 {previewTab === "tools" ? <pre>{t("settings.typography.previewTool")}</pre> : null}
230 </div>
231 </section>
232 </main>
233 </div>
234 </div>
235 );
236 }
237
237 lines Plain Text