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
299 lines
15 KiB
TypeScript
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;
|