Files
MetonaEditor/src/themes.ts
T
thzxx e83fc211dc feat: v0.2.0 — TypeScript full rewrite, 95%+ core coverage
BREAKING CHANGE: All source files converted from JavaScript to TypeScript.
- 12 .ts source files with strict types, full EditorOptions/Plugin/Token interfaces
- 7 .ts test files, 610 total tests (27 new), 7 suites all passing
- tsc --noEmit: 0 errors
- rollup-plugin-typescript build: 5 artifacts (UMD/ESM/CJS/Min/DTS)
- @babel/preset-typescript for jest
- New tsconfig.json, updated babel/jest/rollup configs
- Coverage: parser 99.5%, utils 95.7%, themes 96.2%, core 88.8%, plugins 89.5%
- Removed types/ folder (types now inline in .ts + auto-generated .d.ts)
- Desktop-only, no backward compatibility
2026-07-24 22:28:38 +08:00

299 lines
15 KiB
TypeScript

/**
* 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<string, string | undefined> = {
'--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<string, string> => {
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<string, string> = {};
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<string, string>; 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<ThemeConfig> & { 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<string, ThemeConfig | string> => ({ ...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 as ThemeConfig;
}
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;