返回 DeepSeek-Reasonix
AppearanceOverview.tsx
根目录 / desktop / frontend / src / components / AppearanceOverview.tsx
1 import { SettingsOptions } from "./SettingsOptions";
2 import { SettingsSelect } from "./SettingsSelect";
3 import { useCallback, useEffect, useMemo, useState } from "react";
4 import { Check, Copy, Images, LockKeyhole, Minus, Plus, RotateCcw } from "lucide-react";
5 import { app } from "../lib/bridge";
6 import { useT, type DictKey } from "../lib/i18n";
7 import { THEME_STYLES, type Theme, type ThemeStyle, isThemeStyle } from "../lib/theme";
8 import type { TerminalThemePreference } from "../lib/terminalTheme";
9 import type { ConversationWidth } from "../lib/conversationWidth";
10 import { TEXT_SIZES, type TextSize } from "../lib/textSize";
11 import { type FontFamily, type MonoFontFamily } from "../lib/fontFamily";
12 import { DEFAULT_ZOOM, MIN_ZOOM, MAX_ZOOM, ZOOM_STEP, zoomToPercent, type ZoomLevel } from "../lib/dpiScale";
13 import { getAvailableFontFamilies, getAvailableMonoFontFamilies } from "../lib/fontAvailability";
14 import {
15 type ThemeExperienceView,
16 activateBaseStyle,
17 applyExperienceToDOM,
18 cancelGlobalPreview,
19 configuredBaseStyleForSync,
20 disableThemePack,
21 loadThemeExperience,
22 restoreGraphiteAppearance,
23 setThemeMode,
24 } from "../lib/themeExperience";
25 import { useToast } from "../lib/toast";
26 import { ThemeGallery } from "./ThemeGallery";
27 import { TypographySettings } from "./TypographySettings";
28
29 const STYLE_NAME_KEY: Record<ThemeStyle, DictKey> = {
30 graphite: "settings.style.graphite.zh",
31 aurora: "settings.style.aurora.zh",
32 slate: "settings.style.slate.zh",
33 carbon: "settings.style.carbon.zh",
34 nocturne: "settings.style.nocturne.zh",
35 amber: "settings.style.amber.zh",
36 };
37
38 function textSizeLabel(size: TextSize, t: (key: never) => string): string {
39 switch (size) {
40 case "small":
41 return t("settings.textSizeSmall" as never);
42 case "default":
43 return t("settings.textSizeDefault" as never);
44 case "large":
45 return t("settings.textSizeLarge" as never);
46 case "xlarge":
47 return t("settings.textSizeXLarge" as never);
48 case "xxlarge":
49 return t("settings.textSizeXXLarge" as never);
50 default:
51 return size;
52 }
53 }
54
55 function fontFamilyLabel(font: FontFamily, t: ReturnType<typeof useT>): string {
56 switch (font) {
57 case "system":
58 return t("settings.fontFamilySystem");
59 case "yahei":
60 return t("settings.fontFamilyYaHei");
61 case "pingfang":
62 return t("settings.fontFamilyPingFang");
63 case "noto":
64 return t("settings.fontFamilyNoto");
65 case "custom":
66 return t("settings.fontFamilyCustom");
67 }
68 }
69
70 function monoFontFamilyLabel(font: MonoFontFamily, t: ReturnType<typeof useT>): string {
71 switch (font) {
72 case "system":
73 return t("settings.monoFontFamilySystem");
74 case "cascadia":
75 return t("settings.monoFontFamilyCascadia");
76 case "jetbrains":
77 return t("settings.monoFontFamilyJetBrains");
78 case "sfmono":
79 return t("settings.monoFontFamilySFMono");
80 case "custom":
81 return t("settings.monoFontFamilyCustom");
82 }
83 }
84
85 // Re-export field shells used by SettingsPanel (defined there as local helpers).
86 // AppearanceOverview is rendered inside SettingsPageShell and uses the same CSS.
87
88 export function AppearanceOverview({
89 theme,
90 themeStyle,
91 terminalTheme,
92 conversationWidth,
93 textSize,
94 showDisplayZoom,
95 zoomPct,
96 fontFamily,
97 monoFontFamily,
98 customFontName,
99 customMonoFontName,
100 onTheme,
101 onThemeStyle,
102 onTerminalTheme,
103 onConversationWidth,
104 onTextSize,
105 onRestartZoom,
106 onFontFamily,
107 onMonoFontFamily,
108 onCustomFontNameChange,
109 onCustomMonoFontNameChange,
110 }: {
111 theme: Theme;
112 themeStyle: ThemeStyle;
113 terminalTheme: TerminalThemePreference;
114 conversationWidth: ConversationWidth;
115 textSize: TextSize;
116 showDisplayZoom: boolean;
117 zoomPct: number;
118 fontFamily: FontFamily;
119 monoFontFamily: MonoFontFamily;
120 customFontName: string;
121 customMonoFontName: string;
122 onTheme: (t: Theme) => void;
123 onThemeStyle: (style: ThemeStyle) => void;
124 onTerminalTheme: (theme: TerminalThemePreference) => void;
125 onConversationWidth: (width: ConversationWidth) => void;
126 onTextSize: (size: TextSize) => void;
127 onRestartZoom: (zoom: ZoomLevel) => Promise<void>;
128 onFontFamily: (font: FontFamily) => void;
129 onMonoFontFamily: (font: MonoFontFamily) => void;
130 onCustomFontNameChange: (name: string) => void;
131 onCustomMonoFontNameChange: (name: string) => void;
132 }) {
133 const t = useT();
134 const { showToast } = useToast();
135 const [view, setView] = useState<"overview" | "gallery" | "typography">("overview");
136 const [galleryIntent, setGalleryIntent] = useState<"browse" | "copy-base">("browse");
137 const [experience, setExperience] = useState<ThemeExperienceView | null>(null);
138 const [busy, setBusy] = useState(false);
139
140 const refresh = useCallback(async () => {
141 try {
142 const exp = await loadThemeExperience();
143 setExperience(exp);
144 applyExperienceToDOM(exp);
145 } catch (err) {
146 console.warn("theme experience load failed", err);
147 }
148 }, []);
149
150 useEffect(() => {
151 void refresh();
152 return () => {
153 cancelGlobalPreview();
154 };
155 }, [refresh]);
156
157 // Keep experience in sync when parent theme/style props change from outside.
158 useEffect(() => {
159 setExperience((prev) =>
160 prev
161 ? {
162 ...prev,
163 themeMode: theme,
164 baseStyle: themeStyle,
165 effectiveStyle: prev.activePack?.baseStyle || themeStyle,
166 }
167 : prev,
168 );
169 }, [theme, themeStyle]);
170
171 const availableFontFamilies = useMemo(() => getAvailableFontFamilies(fontFamily), [fontFamily]);
172 const availableMonoFontFamilies = useMemo(() => getAvailableMonoFontFamilies(monoFontFamily), [monoFontFamily]);
173 const zoomMinPct = zoomToPercent(MIN_ZOOM);
174 const zoomMaxPct = zoomToPercent(MAX_ZOOM);
175 const zoomStepPct = Math.round(ZOOM_STEP * 100);
176 const zoomProgressPct = Math.min(100, Math.max(0, ((zoomPct - zoomMinPct) / (zoomMaxPct - zoomMinPct)) * 100));
177 const canDecreaseZoom = zoomPct > zoomMinPct;
178 const canIncreaseZoom = zoomPct < zoomMaxPct;
179 const setZoomPercent = (pct: number) => {
180 void onRestartZoom(pct / 100);
181 };
182
183 const pack = experience?.activePack ?? null;
184 const baseStyle = (isThemeStyle(experience?.baseStyle) ? experience!.baseStyle : themeStyle) as ThemeStyle;
185 const styleNameKey = STYLE_NAME_KEY[baseStyle] || STYLE_NAME_KEY.graphite;
186
187 const currentTitle = pack
188 ? pack.nameKey
189 ? t(pack.nameKey as never)
190 : pack.name
191 : t(styleNameKey);
192 const kindLabel = pack
193 ? pack.kind === "user" || (!pack.builtin && pack.kind !== "official")
194 ? t("settings.themeGallery.kindUser")
195 : pack.kind === "official" || (pack.builtin && pack.id.startsWith("official-"))
196 ? t("settings.themeGallery.kindOfficial")
197 : t("settings.themeGallery.kindBase")
198 : t("settings.themeGallery.kindBase");
199
200 const swatches = pack
201 ? [pack.tokens?.light?.bg || "#fff", pack.tokens?.light?.accent || "#ccc", pack.tokens?.dark?.accent || "#888"]
202 : null;
203
204 const thumbUrl = pack?.previewUrl || pack?.backgroundUrl || "";
205
206 const handleBrowse = () => {
207 setGalleryIntent("browse");
208 setView("gallery");
209 };
210
211 const handleCopy = async () => {
212 if (!pack) {
213 setGalleryIntent("copy-base");
214 setView("gallery");
215 return;
216 }
217 setBusy(true);
218 try {
219 const newId = `${pack.id}-copy`.toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 48);
220 const created = await app.CopyThemePack(pack.id, newId, `${pack.name} Copy`);
221 showToast(t("settings.themeLibrary.copied", { name: created.name }), "info");
222 setView("gallery");
223 } catch (err) {
224 showToast(err instanceof Error ? err.message : String(err), "error");
225 } finally {
226 setBusy(false);
227 }
228 };
229
230 const handleDisable = async () => {
231 setBusy(true);
232 try {
233 const exp = await disableThemePack();
234 setExperience(exp);
235 onThemeStyle(isThemeStyle(exp.baseStyle) ? exp.baseStyle : "graphite");
236 showToast(t("settings.themeGallery.disabled"), "info");
237 } catch (err) {
238 showToast(err instanceof Error ? err.message : String(err), "error");
239 } finally {
240 setBusy(false);
241 }
242 };
243
244 const handleBaseChange = async (style: ThemeStyle) => {
245 setBusy(true);
246 try {
247 const exp = await activateBaseStyle(style);
248 setExperience(exp);
249 onThemeStyle(style);
250 } catch (err) {
251 showToast(err instanceof Error ? err.message : String(err), "error");
252 } finally {
253 setBusy(false);
254 }
255 };
256
257 const handleThemeMode = async (mode: Theme) => {
258 onTheme(mode);
259 try {
260 const exp = await setThemeMode(mode);
261 setExperience(exp);
262 } catch (err) {
263 showToast(err instanceof Error ? err.message : String(err), "error");
264 }
265 };
266
267 if (view === "gallery" && experience) {
268 return (
269 <ThemeGallery
270 experience={experience}
271 initialCreateBaseStyle={galleryIntent === "copy-base" ? baseStyle : undefined}
272 onExperienceChange={(exp) => {
273 setExperience(exp);
274 // Keep the configured base style separate from an active pack's live
275 // effective style. applyExperienceToDOM already owns the pack override;
276 // mirroring it through onThemeStyle would corrupt the restore snapshot.
277 const configuredStyle = configuredBaseStyleForSync(exp);
278 if (configuredStyle) onThemeStyle(configuredStyle);
279 }}
280 onBack={() => {
281 cancelGlobalPreview();
282 setGalleryIntent("browse");
283 setView("overview");
284 void refresh();
285 }}
286 />
287 );
288 }
289
290 if (view === "typography") {
291 return <TypographySettings onBack={() => setView("overview")} />;
292 }
293
294 return (
295 <div className="appearance-overview">
296
297
298 <section className="appearance-overview__current" aria-labelledby="appearance-current-label">
299 <h3 id="appearance-current-label" className="appearance-overview__section-label">
300 {t("settings.themeGallery.currentAppearance")}
301 </h3>
302 <div className="appearance-overview__hero">
303 <div className="appearance-overview__thumb">
304 {thumbUrl ? (
305 <img src={thumbUrl} alt="" loading="lazy" />
306 ) : (
307 <div className="appearance-overview__thumb-base theme-card__swatches" data-theme-style-card={baseStyle}>
308 <span className="theme-card__swatch theme-card__swatch--bg" />
309 <span className="theme-card__swatch theme-card__swatch--surface" />
310 <span className="theme-card__swatch theme-card__swatch--accent" />
311 </div>
312 )}
313 </div>
314 <div className="appearance-overview__hero-meta">
315 <div className="appearance-overview__hero-title-row">
316 <h4 className="appearance-overview__hero-name">{currentTitle}</h4>
317 <span className="theme-gallery__badge">{kindLabel}</span>
318 </div>
319 {swatches ? (
320 <div className="appearance-overview__swatches" aria-hidden="true">
321 {swatches.map((c, i) => (
322 <span key={i} style={{ background: c }} />
323 ))}
324 </div>
325 ) : null}
326 <div className="appearance-overview__in-use">
327 <Check size={14} /> {t("settings.themeLibrary.active")}
328 </div>
329 <div className="appearance-overview__hero-actions">
330 <button type="button" className="btn btn--primary" disabled={busy} onClick={handleBrowse}>
331 <Images size={14} /> {t("settings.themeGallery.browse")}
332 </button>
333 <button type="button" className="btn" disabled={busy} onClick={() => void handleCopy()}>
334 <Copy size={14} /> {t("settings.themeGallery.createCopy")}
335 </button>
336 {pack ? (
337 <button type="button" className="btn" disabled={busy} onClick={() => void handleDisable()}>
338 {t("settings.themeGallery.disable")}
339 </button>
340 ) : null}
341 </div>
342 </div>
343 </div>
344 </section>
345
346 <div className="appearance-overview__rows">
347 <div className="appearance-overview__row">
348 <div id="appearance-theme-mode-label" className="appearance-overview__row-label">{t("settings.theme")}</div>
349 <SettingsOptions
350 layout="field"
351 className="set-seg appearance-overview__segmented appearance-overview__segmented--theme"
352 role="radiogroup"
353 aria-labelledby="appearance-theme-mode-label"
354 >
355 {(["auto", "light", "dark"] as Theme[]).map((opt) => (
356 <button
357 key={opt}
358 type="button"
359 role="radio"
360 aria-checked={theme === opt}
361 className={`set-seg__btn${theme === opt ? " set-seg__btn--on" : ""}`}
362 onClick={() => void handleThemeMode(opt)}
363 >
364 {opt === "auto" ? t("settings.themeAuto") : opt === "light" ? t("settings.themeLight") : t("settings.themeDark")}
365 </button>
366 ))}
367 </SettingsOptions>
368 </div>
369
370 <div className="appearance-overview__row">
371 <div id="appearance-terminal-theme-label" className="appearance-overview__row-label">{t("settings.terminalTheme")}</div>
372 <SettingsOptions
373 layout="field"
374 className="set-seg appearance-overview__segmented appearance-overview__segmented--theme"
375 role="radiogroup"
376 aria-labelledby="appearance-terminal-theme-label"
377 >
378 {(["auto", "light", "dark"] as TerminalThemePreference[]).map((opt) => (
379 <button
380 key={opt}
381 type="button"
382 role="radio"
383 aria-checked={terminalTheme === opt}
384 className={`set-seg__btn${terminalTheme === opt ? " set-seg__btn--on" : ""}`}
385 onClick={() => onTerminalTheme(opt)}
386 >
387 {opt === "auto"
388 ? t("settings.terminalThemeAuto")
389 : opt === "light"
390 ? t("settings.terminalThemeLight")
391 : t("settings.terminalThemeDark")}
392 </button>
393 ))}
394 </SettingsOptions>
395 </div>
396
397 <div className="appearance-overview__row">
398 <div id="appearance-base-style-label" className="appearance-overview__row-label">{t("settings.themeGallery.baseStyle")}</div>
399 <div className="appearance-overview__control-stack">
400 <SettingsSelect
401 className="appearance-overview__select"
402 value={baseStyle}
403 disabled={busy || !!pack}
404 aria-labelledby="appearance-base-style-label"
405 aria-describedby={pack ? "appearance-base-style-help" : undefined}
406 onValueChange={(value) => void handleBaseChange(value as ThemeStyle)}
407 >
408 {THEME_STYLES.map((s) => (
409 <option key={s} value={s}>
410 {t(STYLE_NAME_KEY[s])}
411 </option>
412 ))}
413 </SettingsSelect>
414 {pack ? (
415 <span id="appearance-base-style-help" className="appearance-overview__lock-note">
416 <LockKeyhole size={12} aria-hidden="true" />
417 {t("settings.themeGallery.baseLockedByPack")}
418 </span>
419 ) : null}
420 </div>
421 </div>
422
423 <div className="appearance-overview__row">
424 <div id="appearance-conversation-width-label" className="appearance-overview__row-label">
425 {t("settings.conversationWidth")}
426 </div>
427 <SettingsOptions
428 layout="field"
429 className="set-seg appearance-overview__segmented"
430 role="radiogroup"
431 aria-labelledby="appearance-conversation-width-label"
432 >
433 <button
434 type="button"
435 role="radio"
436 aria-checked={conversationWidth === "standard"}
437 className={`set-seg__btn${conversationWidth === "standard" ? " set-seg__btn--on" : ""}`}
438 onClick={() => onConversationWidth("standard")}
439 >
440 {t("settings.conversationWidthStandard")} (960px)
441 </button>
442 <button
443 type="button"
444 role="radio"
445 aria-checked={conversationWidth === "full"}
446 className={`set-seg__btn${conversationWidth === "full" ? " set-seg__btn--on" : ""}`}
447 onClick={() => onConversationWidth("full")}
448 >
449 {t("settings.conversationWidthFull")} (90%)
450 </button>
451 </SettingsOptions>
452 </div>
453
454 <div className="appearance-overview__row">
455 <div id="appearance-text-size-label" className="appearance-overview__row-label">{t("settings.textSize")}</div>
456 <SettingsOptions
457 layout="field"
458 className="set-seg appearance-overview__segmented appearance-overview__segmented--text-size"
459 role="radiogroup"
460 aria-labelledby="appearance-text-size-label"
461 >
462 {TEXT_SIZES.map((size) => (
463 <button
464 key={size}
465 type="button"
466 role="radio"
467 aria-checked={textSize === size}
468 className={`set-seg__btn${textSize === size ? " set-seg__btn--on" : ""}`}
469 onClick={() => onTextSize(size)}
470 >
471 {textSizeLabel(size, t)}
472 </button>
473 ))}
474 </SettingsOptions>
475 </div>
476
477 <div className="appearance-overview__row">
478 <div id="appearance-font-family-label" className="appearance-overview__row-label">{t("settings.fontFamily")}</div>
479 <SettingsSelect
480 className="appearance-overview__select"
481 value={fontFamily}
482 aria-labelledby="appearance-font-family-label"
483 onValueChange={(value) => onFontFamily(value as FontFamily)}
484 >
485 {availableFontFamilies.map((f) => (
486 <option key={f} value={f}>
487 {fontFamilyLabel(f, t)}
488 </option>
489 ))}
490 </SettingsSelect>
491 </div>
492
493 {fontFamily === "custom" ? (
494 <div className="appearance-overview__row">
495 <div id="appearance-custom-font-name-label" className="appearance-overview__row-label">
496 {t("settings.fontFamilyCustomName")}
497 </div>
498 <textarea
499 className="mem-input appearance-overview__font-input"
500 rows={2}
501 aria-labelledby="appearance-custom-font-name-label"
502 placeholder={t("settings.fontFamilyCustomPlaceholder")}
503 value={customFontName}
504 onChange={(e) => onCustomFontNameChange(e.target.value)}
505 />
506 </div>
507 ) : null}
508
509 <div className="appearance-overview__row">
510 <div id="appearance-mono-font-family-label" className="appearance-overview__row-label">{t("settings.monoFontFamily")}</div>
511 <SettingsSelect
512 className="appearance-overview__select"
513 value={monoFontFamily}
514 aria-labelledby="appearance-mono-font-family-label"
515 onValueChange={(value) => onMonoFontFamily(value as MonoFontFamily)}
516 >
517 {availableMonoFontFamilies.map((f) => (
518 <option key={f} value={f}>
519 {monoFontFamilyLabel(f, t)}
520 </option>
521 ))}
522 </SettingsSelect>
523 </div>
524
525 {monoFontFamily === "custom" ? (
526 <div className="appearance-overview__row">
527 <div id="appearance-custom-mono-font-name-label" className="appearance-overview__row-label">
528 {t("settings.monoFontFamilyCustomName")}
529 </div>
530 <textarea
531 className="mem-input appearance-overview__font-input"
532 rows={2}
533 aria-labelledby="appearance-custom-mono-font-name-label"
534 placeholder={t("settings.monoFontFamilyCustomPlaceholder")}
535 value={customMonoFontName}
536 onChange={(e) => onCustomMonoFontNameChange(e.target.value)}
537 />
538 </div>
539 ) : null}
540
541 <div className="appearance-overview__row appearance-overview__row--typography">
542 <div>
543 <div className="appearance-overview__row-label">{t("settings.typography.title")}</div>
544 <div className="appearance-overview__row-note">{t("settings.typography.entrySummary")}</div>
545 </div>
546 <button type="button" className="btn btn--small" onClick={() => setView("typography")}>
547 {t("settings.typography.open")}
548 </button>
549 </div>
550
551 {showDisplayZoom ? (
552 <div className="appearance-overview__row">
553 <div className="appearance-overview__row-label">{t("settings.displayZoom")}</div>
554 <div className="zoom-slider-wrap">
555 <div className="zoom-slider__head">
556 <div className="zoom-slider__value">{zoomPct}%</div>
557 <div className="zoom-stepper">
558 <button
559 type="button"
560 className="zoom-stepper__btn"
561 aria-label={t("settings.displayZoomDecrease")}
562 title={t("settings.displayZoomDecrease")}
563 disabled={!canDecreaseZoom}
564 onClick={() => setZoomPercent(zoomPct - zoomStepPct)}
565 >
566 <Minus size={13} aria-hidden="true" />
567 </button>
568 <button
569 type="button"
570 className="zoom-stepper__reset"
571 aria-label={t("settings.displayZoomReset")}
572 title={t("settings.displayZoomReset")}
573 disabled={zoomPct === zoomToPercent(DEFAULT_ZOOM)}
574 onClick={() => {
575 void onRestartZoom(DEFAULT_ZOOM);
576 }}
577 >
578 <RotateCcw size={12} aria-hidden="true" />
579 <span>100%</span>
580 </button>
581 <button
582 type="button"
583 className="zoom-stepper__btn"
584 aria-label={t("settings.displayZoomIncrease")}
585 title={t("settings.displayZoomIncrease")}
586 disabled={!canIncreaseZoom}
587 onClick={() => setZoomPercent(zoomPct + zoomStepPct)}
588 >
589 <Plus size={13} aria-hidden="true" />
590 </button>
591 </div>
592 </div>
593 <div className="zoom-slider-row">
594 <span className="zoom-slider__label">{zoomMinPct}%</span>
595 <div className="slider-track">
596 <div className="slider-track__bg" />
597 <div className="slider-track__fill" style={{ width: `calc(${zoomProgressPct}% + 15px)` }} />
598 <div className="slider-thumb" style={{ left: `${zoomProgressPct}%` }} />
599 <input
600 aria-label={t("settings.displayZoom")}
601 type="range"
602 min={zoomMinPct}
603 max={zoomMaxPct}
604 step={zoomStepPct}
605 value={zoomPct}
606 onChange={(e) => setZoomPercent(Number(e.target.value))}
607 />
608 </div>
609 <span className="zoom-slider__label">{zoomMaxPct}%</span>
610 </div>
611 </div>
612 </div>
613 ) : null}
614
615 <div className="appearance-overview__row appearance-overview__row--footer">
616 <span id="appearance-restore-help" className="appearance-overview__reset-hint">
617 {t("settings.themeGallery.restoreGraphiteHint")}
618 </span>
619 <button
620 type="button"
621 className="btn btn--small"
622 aria-describedby="appearance-restore-help"
623 disabled={busy}
624 onClick={() => {
625 void (async () => {
626 setBusy(true);
627 try {
628 const exp = await restoreGraphiteAppearance();
629 setExperience(exp);
630 onThemeStyle("graphite");
631 showToast(t("settings.themeGallery.restoredGraphite"), "info");
632 } catch (err) {
633 showToast(err instanceof Error ? err.message : String(err), "error");
634 } finally {
635 setBusy(false);
636 }
637 })();
638 }}
639 >
640 {t("settings.themeGallery.restoreGraphite")}
641 </button>
642 </div>
643 </div>
644 </div>
645 );
646 }
647
647 lines Plain Text