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