/** * MetonaEditor Themes — theme system * @module themes * @version 0.2.0 */ import { THEMES } from './constants'; import { prefersDark } from './utils'; import type { ThemeConfig, ThemeName } from './constants'; // ============ State ============ let globalCurrentTheme = 'auto'; let globalThemeListeners = new Set<(theme: string, resolved: string) => void>(); let _sysWatchUnsub: (() => void) | null = null; // ============ System theme ============ export const getSystemTheme = (): 'light' | 'dark' => { if (typeof window === 'undefined') return 'light'; return prefersDark() ? 'dark' : 'light'; }; const _watchSystem = (callback: (theme: 'light' | 'dark') => void): (() => void) => { if (typeof window === 'undefined' || !window.matchMedia) return () => {}; const mql = window.matchMedia('(prefers-color-scheme: dark)'); const handler = (e: MediaQueryListEvent) => callback(e.matches ? 'dark' : 'light'); mql.addEventListener('change', handler); return () => mql.removeEventListener('change', handler); }; export const watchSystemTheme = (): void => { if (typeof window === 'undefined' || !window.matchMedia) return; if (_sysWatchUnsub) { _sysWatchUnsub(); _sysWatchUnsub = null; } _sysWatchUnsub = _watchSystem((sysTheme) => { if (globalCurrentTheme === 'auto') applyTheme('auto'); }); }; export const unwatchSystemTheme = (): void => { if (_sysWatchUnsub) { _sysWatchUnsub(); _sysWatchUnsub = null; } }; // ============ Theme resolution ============ export const resolveTheme = (theme: string): string => { if (!theme || theme === 'auto') return getSystemTheme(); return theme; }; export const getTheme = resolveTheme; export const getThemeConfig = (theme: string): ThemeConfig => { const resolved = resolveTheme(theme); const config = THEMES[resolved]; return (config && typeof config === 'object' ? config : THEMES.light) as ThemeConfig; }; // ============ CSS variables ============ export const setThemeVariables = (config: ThemeConfig, target?: HTMLElement): void => { if (typeof document === 'undefined' || !config || typeof config !== 'object') return; const root = target || document.documentElement; const vars: Record = { '--md-bg': config.bg, '--md-text': config.text, '--md-border': config.border, '--md-shadow': config.shadow, '--md-hover-shadow': config.hoverShadow, '--md-toolbar-bg': config.toolbarBg || config.bg, '--md-textarea-bg': config.textareaBg || config.bg, '--md-preview-bg': config.previewBg || config.bg, '--md-code-bg': config.codeBg || 'rgba(127,127,127,0.1)', '--md-code-text': config.codeText || config.text, '--md-accent': config.accent || '#3b82f6', '--md-muted': config.muted || '#9ca3af', }; for (const [k, v] of Object.entries(vars)) { if (v !== undefined && v !== null) root.style.setProperty(k, String(v)); } if (config.progressBg) root.style.setProperty('--md-progress-bg', config.progressBg); if (config.closeHoverBg) root.style.setProperty('--md-close-hover-bg', config.closeHoverBg); }; export const exportCSSVars = (el?: HTMLElement): Record => { const root = el || document.documentElement; if (typeof document === 'undefined') return {}; const style = getComputedStyle(root); const varNames = ['--md-bg','--md-text','--md-border','--md-shadow','--md-hover-shadow','--md-toolbar-bg','--md-textarea-bg','--md-preview-bg','--md-code-bg','--md-code-text','--md-accent','--md-muted']; const result: Record = {}; varNames.forEach((name) => { const val = style.getPropertyValue(name).trim(); if (val) result[name] = val; }); return result; }; export const getCSSVariable = (name: string, el?: HTMLElement): string => { const root = el || document.documentElement; if (typeof document === 'undefined') return ''; const fullName = name.startsWith('--') ? name : `--md-${name}`; return getComputedStyle(root).getPropertyValue(fullName).trim(); }; // ============ Apply theme ============ export const applyTheme = (theme: string): void => { globalCurrentTheme = theme; const resolved = resolveTheme(theme); if (typeof document !== 'undefined') { document.documentElement.setAttribute('data-md-theme', resolved); document.documentElement.classList.remove('md-theme-light','md-theme-dark','md-theme-auto','md-theme-warm'); document.documentElement.classList.add(`md-theme-${resolved}`); setThemeVariables(getThemeConfig(theme)); } notifyGlobalListeners(theme, resolved); }; export const applyThemeToElement = (theme: string, target: HTMLElement): void => { if (!target || typeof document === 'undefined') return; const resolved = resolveTheme(theme); target.setAttribute('data-md-theme', resolved); target.classList.remove('md-theme-light','md-theme-dark','md-theme-auto','md-theme-warm'); if (resolved !== 'auto') target.classList.add(`md-theme-${resolved}`); setThemeVariables(getThemeConfig(theme), target); }; export const switchTheme = (theme: string): void => { applyTheme(theme); saveTheme(theme); }; export const toggleTheme = (): void => { const r = getResolvedTheme(); switchTheme(r === 'dark' ? 'light' : 'dark'); }; export const resetToAuto = (): void => { switchTheme('auto'); }; export const getCurrentTheme = (): string => globalCurrentTheme; export const getResolvedTheme = (): string => resolveTheme(globalCurrentTheme); // ============ Persistence ============ export const saveTheme = (theme: string): void => { if (typeof localStorage !== 'undefined') { try { localStorage.setItem('metona-editor-theme', theme); } catch (_) {} } }; export const loadTheme = (): string => { if (typeof localStorage !== 'undefined') { try { return localStorage.getItem('metona-editor-theme') || 'auto'; } catch (_) {} } return 'auto'; }; // ============ Listeners ============ const notifyGlobalListeners = (theme: string, resolved: string): void => { globalThemeListeners.forEach((fn) => { try { fn(theme, resolved); } catch (e) { console.error('Theme listener error:', e); } }); }; export const addThemeListener = (fn: (theme: string, resolved: string) => void): () => void => { globalThemeListeners.add(fn); return () => { globalThemeListeners.delete(fn); }; }; export const removeThemeListener = (fn: (theme: string, resolved: string) => void): void => { globalThemeListeners.delete(fn); }; export const clearThemeListeners = (): void => { globalThemeListeners.clear(); }; // ============ System watch ============ export const startSystemWatch = (): void => { watchSystemTheme(); }; export const stopSystemWatch = unwatchSystemTheme; // ============ Init ============ export const initTheme = (): void => { applyTheme(loadTheme()); startSystemWatch(); }; // ============ External follow ============ export const followExternalTheme = ( options: { element?: HTMLElement; attr?: string; classMap?: Record; callback?: (el: HTMLElement) => string | null }, onThemeChange: (theme: string) => void, ): () => void => { if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return () => {}; const { element = document.documentElement, attr = 'data-theme', classMap = null, callback = null } = options; const detect = (): string | null => { if (typeof callback === 'function') return callback(element); const attrVal = element.getAttribute(attr); if (attrVal) return attrVal; if (classMap) { for (const [cls, theme] of Object.entries(classMap)) { if (element.classList.contains(cls)) return theme; } } const mdTheme = element.getAttribute('data-md-theme'); if (mdTheme) return mdTheme; return null; }; let lastTheme = detect(); if (lastTheme) onThemeChange(lastTheme); const observer = new MutationObserver(() => { const current = detect(); if (current && current !== lastTheme) { lastTheme = current; onThemeChange(current); } }); observer.observe(element, { attributes: true, attributeFilter: [attr, 'class', 'data-md-theme'] }); return () => observer.disconnect(); }; export const adoptFromParent = (container: HTMLElement, onThemeDetected: (theme: string) => void): () => void => { if (typeof document === 'undefined' || !container) return () => {}; const detect = (): string | null => { let el: HTMLElement | null = container.parentElement; while (el) { const t = el.getAttribute('data-md-theme'); if (t) return t; if (el.classList.contains('theme-dark') || el.classList.contains('dark')) return 'dark'; if (el.classList.contains('theme-light') || el.classList.contains('light')) return 'light'; el = el.parentElement; } if (container.parentElement) { const bg = getComputedStyle(container.parentElement).getPropertyValue('--md-bg').trim() || getComputedStyle(container.parentElement).backgroundColor; if (bg) { const rgb = bg.match(/\d+/g); if (rgb && rgb.length >= 3) { const brightness = (parseInt(rgb[0])*299 + parseInt(rgb[1])*587 + parseInt(rgb[2])*114)/1000; return brightness < 128 ? 'dark' : 'light'; } } } return null; }; const detected = detect(); if (detected) onThemeDetected(detected); const observer = new MutationObserver(() => { const d = detect(); if (d) onThemeDetected(d); }); let target: HTMLElement | null = container.parentElement; while (target) { observer.observe(target, { attributes: true, attributeFilter: ['class','data-md-theme','data-theme'] }); target = target.parentElement; if (target === document.documentElement) break; } return () => observer.disconnect(); }; export const watch = (source: Function | string, onChange: (theme: string) => void): () => void => { if (typeof source === 'function') { const tick = () => { try { const t = source(); if (t) onChange(t); } catch (_) {} }; tick(); const id = setInterval(tick, 500); return () => clearInterval(id); } if (typeof source === 'string' && typeof document !== 'undefined') { const el = document.querySelector(source); if (!el) return () => {}; return followExternalTheme({ element: el as HTMLElement, attr: 'data-theme' }, onChange); } return () => {}; }; // ============ Custom theme registration ============ export const registerTheme = (name: string, config: Partial & { extends?: string } = {}): void => { const baseName = config.extends || 'light'; const base = (THEMES[baseName] || THEMES.light) as ThemeConfig; THEMES[name] = { bg: config.bg || base.bg, text: config.text || base.text, border: config.border || base.border, shadow: config.shadow || base.shadow, hoverShadow: config.hoverShadow || base.hoverShadow, toolbarBg: config.toolbarBg || base.toolbarBg || base.bg, textareaBg: config.textareaBg || base.textareaBg || base.bg, previewBg: config.previewBg || base.previewBg || base.bg, codeBg: config.codeBg || base.codeBg, codeText: config.codeText || base.codeText, accent: config.accent || base.accent, muted: config.muted || base.muted, progressBg: config.progressBg || base.progressBg, closeHoverBg: config.closeHoverBg || base.closeHoverBg, }; }; export const unregisterTheme = (name: string): void => { if (name === 'light' || name === 'dark' || name === 'auto' || name === 'warm') { console.warn('Cannot unregister built-in theme:', name); return; } delete THEMES[name]; }; export const getAllThemes = (): Record => ({ ...THEMES }); export const getThemeNames = (): string[] => Object.keys(THEMES); export const hasTheme = (name: string): boolean => name in THEMES; // ============ Instance theme ============ export const createInstanceTheme = (editor: any): any => { let instanceTheme = editor.config?.theme || globalCurrentTheme || 'auto'; const apply = (theme: string): string => { instanceTheme = theme; const resolved = resolveTheme(theme); const config = getThemeConfig(theme); if (editor.el) { ['me-theme-light','me-theme-dark','me-theme-warm'].forEach((c) => editor.el.classList.remove(c)); if (resolved !== 'auto') editor.el.classList.add(`me-theme-${resolved}`); editor.el.setAttribute('data-md-theme', resolved); setThemeVariables(config, editor.el); } if (typeof editor._emit === 'function') editor._emit('themeChange', { theme, resolved, config }); if (typeof editor.refresh === 'function') editor.refresh(); return instanceTheme; }; const get = () => instanceTheme; const set = (theme: string) => apply(theme); const toggle = () => { const r = resolveTheme(instanceTheme); return apply(r === 'dark' ? 'light' : 'dark'); }; const getResolved = () => resolveTheme(instanceTheme); const getConfig = () => getThemeConfig(instanceTheme); const getVars = () => editor.el ? exportCSSVars(editor.el) : {}; const syncWithElement = (element: HTMLElement, opts: any = {}) => followExternalTheme({ element, attr: opts.attr || 'data-theme', classMap: opts.classMap, callback: opts.callback }, (detected) => apply(detected)); const adopt = () => editor.container ? adoptFromParent(editor.container, (detected) => apply(detected)) : () => {}; const watchExternal = (source: any) => watch(source, (theme) => apply(theme)); apply(instanceTheme); return { apply, get, set, toggle, getResolved, getConfig, getVars, syncWithElement, adopt, watch: watchExternal }; }; export const createThemeManager = () => ({ getSystemTheme, resolveTheme, getThemeConfig, applyTheme, getCurrentTheme, getResolvedTheme, switchTheme, toggleTheme, resetToAuto, initTheme, watchSystemTheme, unwatchSystemTheme, addThemeListener, removeThemeListener, clearThemeListeners, registerTheme, unregisterTheme, getAllThemes, getThemeNames, hasTheme, saveTheme, loadTheme, setThemeVariables, exportCSSVars, getCSSVariable, applyThemeToElement, followExternalTheme, adoptFromParent, watch, createInstanceTheme, }); export const presetThemes = { light: { name: '浅色', description: '明亮清晰', config: THEMES.light }, dark: { name: '深色', description: '护眼暗色', config: THEMES.dark }, auto: { name: '自动', description: '跟随系统', config: 'auto' }, warm: { name: '暖色', description: '温馨暖色', config: THEMES.warm }, }; // Ensure built-in themes in THEMES for (const [name, theme] of Object.entries(presetThemes)) { if (name !== 'auto' && theme.config !== 'auto') THEMES[name] = theme.config; } export const themeUtils = { getSystemTheme, resolveTheme, getThemeConfig, watchSystemTheme, unwatchSystemTheme, applyTheme, getCurrentTheme, getResolvedTheme, switchTheme, toggleTheme, resetToAuto, initTheme, saveTheme, loadTheme, addThemeListener, removeThemeListener, clearThemeListeners, registerTheme, unregisterTheme, getAllThemes, getThemeNames, hasTheme, setThemeVariables, exportCSSVars, getCSSVariable, applyThemeToElement, followExternalTheme, adoptFromParent, watch, createInstanceTheme, }; export default themeUtils;