ribbit/src/ts/theme-manager.ts

156 lines
4.4 KiB
TypeScript
Raw Normal View History

/*
* theme-manager.ts manages theme registration and activation for a Ribbit instance.
*/
import type { RibbitTheme } from './types';
/** CSS file name loaded from each theme's directory. */
const THEME_CSS_FILENAME = 'theme.css';
/**
* Manages theme registration, enabling/disabling, and CSS loading
* for a ribbit editor instance.
*
* @example
* const themes = new ThemeManager(defaultTheme, '/themes', (current, previous) => {
* editor.rebuild();
* });
* themes.add(customTheme);
* themes.set('custom');
*/
export class ThemeManager {
private registered: Map<string, RibbitTheme>;
private disabled: Set<string>;
private active: RibbitTheme;
private themeLink: HTMLLinkElement | null;
private themesPath: string;
private onSwitch: (theme: RibbitTheme, previous: RibbitTheme) => void;
constructor(initial: RibbitTheme, themesPath: string, onSwitch: (theme: RibbitTheme, previous: RibbitTheme) => void) {
this.registered = new Map();
this.disabled = new Set();
this.themeLink = null;
this.themesPath = themesPath;
this.onSwitch = onSwitch;
this.active = initial;
this.add(initial);
}
/**
* Register a theme. Themes must be added before they can be activated.
*
* @example
* themes.add({ name: 'dark', tags: darkTags });
*/
add(theme: RibbitTheme): void {
this.registered.set(theme.name, theme);
}
/**
* Unregister a theme by name. Cannot remove the active theme.
*
* @example
* themes.remove('dark');
*/
remove(name: string): void {
if (this.active.name === name) {
throw new Error(`Cannot remove the active theme "${name}".`);
}
this.registered.delete(name);
}
/**
* Return the names of all registered and enabled themes.
*
* @example
* const available = themes.list(); // ['ribbit-default', 'dark']
*/
list(): string[] {
return Array.from(this.registered.keys()).filter(name => !this.disabled.has(name));
}
/**
* Get a registered theme by name, or undefined if not found.
*
* @example
* const theme = themes.get('dark');
*/
get(name: string): RibbitTheme | undefined {
return this.registered.get(name);
}
/**
* Return the currently active theme.
*
* @example
* const active = themes.current();
*/
current(): RibbitTheme {
return this.active;
}
/**
* Switch to a registered theme by name. The theme must be
* registered and enabled. Loads the theme's CSS and notifies
* the editor to rebuild its converter.
*
* @example
* themes.set('dark');
*/
set(name: string): void {
const theme = this.registered.get(name);
if (!theme) {
throw new Error(`Theme "${name}" is not registered. Call add() first.`);
}
if (this.disabled.has(name)) {
throw new Error(`Theme "${name}" is disabled. Call enable() first.`);
}
const previous = this.active;
this.active = theme;
// Only load CSS when actually switching to a different theme
2026-04-29 11:12:45 -07:00
if (previous !== theme) {
this.loadCSS(name);
}
this.onSwitch(theme, previous);
}
/**
* Mark a theme as available for selection via set().
* Themes are enabled by default when added.
*
* @example
* themes.enable('dark');
*/
enable(name: string): void {
if (!this.registered.has(name)) {
throw new Error(`Theme "${name}" is not registered. Call add() first.`);
}
this.disabled.delete(name);
}
/**
* Mark a theme as unavailable for selection via set().
* Does not affect the current theme if it is already active.
*
* @example
* themes.disable('dark');
*/
disable(name: string): void {
if (!this.registered.has(name)) {
throw new Error(`Theme "${name}" is not registered.`);
}
this.disabled.add(name);
}
private loadCSS(name: string): void {
if (this.themeLink) {
this.themeLink.remove();
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `${this.themesPath}/${name}/${THEME_CSS_FILENAME}`;
document.head.appendChild(link);
this.themeLink = link;
}
}