/* * 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; private disabled: Set; 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 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; } }