| /* |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| import { Injectable, OnDestroy } from '@angular/core'; |
| import { combineLatest, fromEvent, BehaviorSubject, Observable, Subscription } from 'rxjs'; |
| import { distinctUntilChanged, map, startWith } from 'rxjs/operators'; |
| |
| export type ThemeMode = 'light' | 'dark' | 'system'; |
| |
| const THEME_STORAGE_KEY = 'zeppelin-theme'; |
| const MONACO_THEMES = { |
| light: 'vs', |
| dark: 'vs-dark' |
| } as const; |
| |
| @Injectable({ |
| providedIn: 'root' |
| }) |
| export class ThemeService implements OnDestroy { |
| private themeSubject: BehaviorSubject<ThemeMode>; |
| private effectiveThemeSubject: BehaviorSubject<'light' | 'dark'>; |
| private systemStartedWith: 'light' | 'dark' | null = null; |
| private subscriptions = new Subscription(); |
| |
| public theme$: Observable<ThemeMode>; |
| public effectiveTheme$: Observable<'light' | 'dark'>; |
| |
| ngOnDestroy() { |
| this.subscriptions.unsubscribe(); |
| this.themeSubject.complete(); |
| this.effectiveThemeSubject.complete(); |
| } |
| |
| constructor() { |
| const initialTheme = this.detectInitialTheme(); |
| this.themeSubject = new BehaviorSubject<ThemeMode>(initialTheme); |
| this.theme$ = this.themeSubject.asObservable(); |
| |
| // Create observable for system theme changes |
| const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); |
| const systemTheme$ = fromEvent<MediaQueryListEvent>(mediaQuery, 'change').pipe( |
| map(e => (e.matches ? ('dark' as const) : ('light' as const))), |
| startWith(mediaQuery.matches ? ('dark' as const) : ('light' as const)), |
| distinctUntilChanged() |
| ); |
| |
| // Calculate initial effective theme |
| const initialEffectiveTheme = this.resolveEffectiveTheme(initialTheme, mediaQuery.matches); |
| this.effectiveThemeSubject = new BehaviorSubject<'light' | 'dark'>(initialEffectiveTheme); |
| this.effectiveTheme$ = this.effectiveThemeSubject.asObservable(); |
| |
| // Reactively update effective theme when either theme or system theme changes |
| const subscription = combineLatest([this.theme$, systemTheme$]) |
| .pipe( |
| map(([theme, systemTheme]) => (theme === 'system' ? systemTheme : theme)), |
| distinctUntilChanged() |
| ) |
| .subscribe(effectiveTheme => { |
| this.effectiveThemeSubject.next(effectiveTheme); |
| this.applyTheme(effectiveTheme); |
| }); |
| |
| this.subscriptions.add(subscription); |
| } |
| |
| private detectInitialTheme(): ThemeMode { |
| try { |
| const savedTheme = localStorage.getItem(THEME_STORAGE_KEY); |
| if (savedTheme && this.isValidTheme(savedTheme)) { |
| return savedTheme; |
| } |
| return 'system'; |
| } catch { |
| return 'system'; |
| } |
| } |
| |
| private isValidTheme(theme: string): theme is ThemeMode { |
| return theme === 'light' || theme === 'dark' || theme === 'system'; |
| } |
| |
| private resolveEffectiveTheme(theme: ThemeMode, isDarkMode: boolean): 'light' | 'dark' { |
| return theme === 'system' ? (isDarkMode ? 'dark' : 'light') : theme; |
| } |
| |
| getCurrentTheme(): ThemeMode { |
| return this.themeSubject.value; |
| } |
| |
| private getEffectiveTheme(): 'light' | 'dark' { |
| return this.effectiveThemeSubject.value; |
| } |
| |
| private setTheme(theme: ThemeMode, save: boolean = true) { |
| if (this.themeSubject.value === theme) { |
| return; |
| } |
| |
| this.themeSubject.next(theme); |
| |
| if (save) { |
| localStorage.setItem(THEME_STORAGE_KEY, theme); |
| } |
| } |
| |
| toggleTheme() { |
| const currentTheme = this.getCurrentTheme(); |
| let nextTheme: ThemeMode; |
| |
| if (currentTheme === 'light') { |
| nextTheme = 'dark'; |
| } else if (currentTheme === 'dark') { |
| nextTheme = 'system'; |
| } else { |
| nextTheme = 'light'; |
| } |
| |
| this.setTheme(nextTheme); |
| } |
| |
| private applyTheme(effectiveTheme: 'light' | 'dark') { |
| const html = document.documentElement; |
| const body = document.body; |
| |
| [html, body].forEach(el => { |
| el.classList.toggle('dark', effectiveTheme === 'dark'); |
| el.classList.toggle('light', effectiveTheme === 'light'); |
| el.setAttribute('data-theme', effectiveTheme); |
| }); |
| |
| html.style.setProperty('color-scheme', effectiveTheme); |
| |
| this.updateMonacoTheme(); |
| } |
| |
| updateMonacoTheme() { |
| if (!monaco?.editor) { |
| return; |
| } |
| |
| const effectiveTheme = this.getEffectiveTheme(); |
| |
| try { |
| // Fix editor not applying dark mode on first load when theme is set to "system" |
| requestAnimationFrame(() => { |
| monaco.editor.setTheme(MONACO_THEMES[effectiveTheme]); |
| }); |
| } catch (error) { |
| console.error('Monaco theme setting failed:', error); |
| } |
| } |
| } |