| 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 |