返回 DeepSeek-Reasonix
themeExperience.ts
根目录 / desktop / frontend / src / lib / themeExperience.ts
1 // Unified theme experience controller for the redesigned appearance overview
2 // and theme gallery. Backend is source of truth for persistence; selected and
3 // temporary-preview ids stay in memory only.
4
5 import { app } from "./bridge";
6 import {
7 applyThemePack,
8 beginThemePreview,
9 cancelThemePreview,
10 clearPreviewSnapshotOnly,
11 clearThemePack,
12 commitThemePreview,
13 setBaseAppearance,
14 themePackKind,
15 type ThemePackView,
16 } from "./themePack";
17 import { applyTheme, isThemeStyle, type Theme, type ThemeStyle } from "./theme";
18
19 export type ThemeExperienceView = {
20 themeMode: Theme | string;
21 baseStyle: ThemeStyle | string;
22 effectiveStyle: ThemeStyle | string;
23 activeThemeId?: string;
24 activePack?: ThemePackView | null;
25 /** Non-fatal plugin theme discovery issues (invalid files skipped). */
26 warnings?: string[];
27 };
28
29 export type GalleryTab = "catalog" | "user";
30
31 export type ThemeSelection =
32 | { kind: "base"; id: ThemeStyle; pack?: ThemePackView }
33 | { kind: "official" | "user" | "plugin"; id: string; pack: ThemePackView };
34
35 let experienceCache: ThemeExperienceView | null = null;
36 let previewDepth = 0;
37
38 export function getCachedThemeExperience(): ThemeExperienceView | null {
39 return experienceCache;
40 }
41
42 /**
43 * Return the configured base style that React owners should mirror.
44 * An active pack's effectiveStyle is intentionally excluded: it is a live DOM
45 * override, not the persisted base appearance restored when the pack is cleared.
46 */
47 export function configuredBaseStyleForSync(view: ThemeExperienceView): ThemeStyle | null {
48 if (view.activePack) return null;
49 return (isThemeStyle(view.baseStyle) ? view.baseStyle : "graphite") as ThemeStyle;
50 }
51
52 export async function loadThemeExperience(): Promise<ThemeExperienceView> {
53 // Prefer the unified API; fall back for older shells / partial mocks.
54 const api = app as typeof app & {
55 GetThemeExperience?: () => Promise<ThemeExperienceView>;
56 ActivateBaseStyle?: (style: string) => Promise<void>;
57 DisableThemePack?: () => Promise<void>;
58 RestoreGraphiteAppearance?: () => Promise<void>;
59 };
60 if (typeof api.GetThemeExperience === "function") {
61 try {
62 const view = await api.GetThemeExperience();
63 experienceCache = normalizeExperience(view);
64 return experienceCache;
65 } catch {
66 // Fall through to partial Settings / GetActiveThemePack path.
67 }
68 }
69
70 // Partial test mocks and older shells may only expose Settings + GetActiveThemePack.
71 let themeMode: Theme = "auto";
72 let baseStyle: ThemeStyle = "graphite";
73 try {
74 const settingsApi = app as typeof app & {
75 DesktopStartupSettings?: () => Promise<{ desktopTheme?: string; desktopThemeStyle?: string }>;
76 Settings?: () => Promise<{ desktopTheme?: string; desktopThemeStyle?: string }>;
77 };
78 const settings =
79 typeof settingsApi.DesktopStartupSettings === "function"
80 ? await settingsApi.DesktopStartupSettings()
81 : typeof settingsApi.Settings === "function"
82 ? await settingsApi.Settings()
83 : null;
84 if (settings) {
85 themeMode = (settings.desktopTheme as Theme) || "auto";
86 baseStyle = (isThemeStyle(settings.desktopThemeStyle) ? settings.desktopThemeStyle : "graphite") as ThemeStyle;
87 }
88 } catch {
89 // Keep defaults.
90 }
91
92 let activePack: ThemePackView | null = null;
93 let activeThemeId: string | undefined;
94 try {
95 if (typeof app.GetActiveThemePack === "function") {
96 const active = await app.GetActiveThemePack();
97 activePack = active?.pack ?? null;
98 activeThemeId = active?.activeThemeId || undefined;
99 }
100 } catch {
101 // Keep pack unset.
102 }
103
104 const view: ThemeExperienceView = {
105 themeMode,
106 baseStyle,
107 effectiveStyle: activePack?.baseStyle || baseStyle,
108 activeThemeId,
109 activePack,
110 };
111 experienceCache = normalizeExperience(view);
112 return experienceCache;
113 }
114
115 function normalizeExperience(view: ThemeExperienceView): ThemeExperienceView {
116 const themeMode = view.themeMode === "light" || view.themeMode === "dark" || view.themeMode === "auto" ? view.themeMode : "auto";
117 const baseStyle = isThemeStyle(view.baseStyle) ? view.baseStyle : "graphite";
118 const effectiveStyle = isThemeStyle(view.effectiveStyle) ? view.effectiveStyle : baseStyle;
119 return {
120 themeMode,
121 baseStyle,
122 effectiveStyle,
123 activeThemeId: view.activeThemeId || undefined,
124 activePack: view.activePack ?? null,
125 warnings: Array.isArray(view.warnings) ? view.warnings.filter((w): w is string => typeof w === "string" && w.trim().length > 0) : undefined,
126 };
127 }
128
129 /** Apply a loaded experience to the live DOM (no network). */
130 export function applyExperienceToDOM(view: ThemeExperienceView): void {
131 const theme = (view.themeMode === "light" || view.themeMode === "dark" || view.themeMode === "auto" ? view.themeMode : "auto") as Theme;
132 const base = (isThemeStyle(view.baseStyle) ? view.baseStyle : "graphite") as ThemeStyle;
133 setBaseAppearance(theme, base);
134 if (!view.activePack) {
135 clearThemePack();
136 applyTheme(theme, base, { persist: false });
137 return;
138 }
139 applyTheme(theme, base, { persist: false });
140 applyThemePack(view.activePack);
141 }
142
143 export async function activateBaseStyle(style: ThemeStyle): Promise<ThemeExperienceView> {
144 const api = app as typeof app & { ActivateBaseStyle?: (style: string) => Promise<void> };
145 if (typeof api.ActivateBaseStyle === "function") {
146 await api.ActivateBaseStyle(style);
147 } else {
148 // Legacy fallback: clear pack then set appearance.
149 await app.ResetThemePack();
150 const theme = (experienceCache?.themeMode as Theme) || "auto";
151 await app.SetDesktopAppearance(theme, style);
152 }
153 endPreviewIfAny();
154 const view = await loadThemeExperience();
155 applyExperienceToDOM(view);
156 return view;
157 }
158
159 export async function activateThemePack(id: string): Promise<ThemeExperienceView> {
160 await app.ActivateThemePack(id);
161 // Commit the preview only after persistence succeeds. If activation fails,
162 // the snapshot must remain available so Back/Cancel can restore the prior
163 // appearance.
164 clearPreviewSnapshotOnly();
165 endPreviewIfAny();
166 const view = await loadThemeExperience();
167 applyExperienceToDOM(view);
168 return view;
169 }
170
171 export async function disableThemePack(): Promise<ThemeExperienceView> {
172 const api = app as typeof app & { DisableThemePack?: () => Promise<void> };
173 if (typeof api.DisableThemePack === "function") {
174 await api.DisableThemePack();
175 } else {
176 await app.ResetThemePack();
177 }
178 endPreviewIfAny();
179 const view = await loadThemeExperience();
180 applyExperienceToDOM(view);
181 return view;
182 }
183
184 export async function restoreGraphiteAppearance(): Promise<ThemeExperienceView> {
185 const api = app as typeof app & { RestoreGraphiteAppearance?: () => Promise<void> };
186 if (typeof api.RestoreGraphiteAppearance === "function") {
187 await api.RestoreGraphiteAppearance();
188 } else {
189 await app.ResetThemePack();
190 const theme = (experienceCache?.themeMode as Theme) || "auto";
191 await app.SetDesktopAppearance(theme, "graphite");
192 }
193 endPreviewIfAny();
194 const view = await loadThemeExperience();
195 applyExperienceToDOM(view);
196 return view;
197 }
198
199 export async function setThemeMode(mode: Theme): Promise<ThemeExperienceView> {
200 const base = (isThemeStyle(experienceCache?.baseStyle) ? experienceCache!.baseStyle : "graphite") as ThemeStyle;
201 await app.SetDesktopAppearance(mode, base);
202 const view = await loadThemeExperience();
203 // Theme mode is independent of pack — re-apply pack on top.
204 applyExperienceToDOM(view);
205 return view;
206 }
207
208 export function startGlobalPreview(pack: ThemePackView): void {
209 previewDepth += 1;
210 beginThemePreview(pack);
211 }
212
213 export function cancelGlobalPreview(): void {
214 if (previewDepth <= 0) return;
215 previewDepth = 0;
216 cancelThemePreview();
217 }
218
219 export function commitGlobalPreview(pack: ThemePackView | null): void {
220 previewDepth = 0;
221 commitThemePreview(pack);
222 }
223
224 function endPreviewIfAny(): void {
225 if (previewDepth > 0) {
226 previewDepth = 0;
227 cancelThemePreview();
228 }
229 }
230
231 export function isPreviewActive(): boolean {
232 return previewDepth > 0;
233 }
234
235 /** Group packs for the gallery tabs. */
236 export function groupThemePacks(packs: ThemePackView[]): {
237 official: ThemePackView[];
238 user: ThemePackView[];
239 base: ThemePackView[];
240 plugin: ThemePackView[];
241 } {
242 const official: ThemePackView[] = [];
243 const user: ThemePackView[] = [];
244 const base: ThemePackView[] = [];
245 const plugin: ThemePackView[] = [];
246 for (const p of packs) {
247 const k = themePackKind(p);
248 if (k === "official") official.push(p);
249 else if (k === "base") base.push(p);
250 else if (k === "plugin") plugin.push(p);
251 else user.push(p);
252 }
253 return { official, user, base, plugin };
254 }
255
256 export function selectionFromPack(pack: ThemePackView): ThemeSelection {
257 const k = themePackKind(pack);
258 if (k === "base") {
259 return { kind: "base", id: (isThemeStyle(pack.id) ? pack.id : "graphite") as ThemeStyle, pack };
260 }
261 return { kind: k, id: pack.id, pack };
262 }
263
264 export function isSelectionActive(sel: ThemeSelection | null, exp: ThemeExperienceView | null): boolean {
265 if (!sel || !exp) return false;
266 if (sel.kind === "base") {
267 return !exp.activeThemeId && exp.baseStyle === sel.id;
268 }
269 return exp.activeThemeId === sel.id;
270 }
271
271 lines TYPESCRIPT