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