Files
MetonaEditor/src/themes.js
T
thzxx b9e98a4ff4 release: v0.1.14 — syntax highlight, Mermaid, fileSystem, preview links
feat(core): syntax highlight editor overlay
- Transparent highlight-layer behind textarea with regex-based token coloring
- Supports heading, bold, italic, strikethrough, code, link, image, list, quote, hr
- Scroll-synced with textarea, config.syntaxHighlight toggle

feat(core): preview link click interception
- Clicks on preview links intercepted, emitted as 'linkClick' event
- Configurable via onLinkClick(uri, text, editor) callback
- Default: opens in new tab, anchor links pass through

feat(parser): Mermaid diagram rendering
- ```mermaid code blocks render as <div class="me-mermaid"><pre class="mermaid">

feat(plugins): fileSystem plugin (IndexedDB persistence)
- saveToFile(id) / loadFromFile(id) / deleteFile(id) / listFiles()
- Uses IndexedDB 'metona-editor-fs' database

feat(config): maxLength (char limit), syntaxHighlight toggle

style: syntax highlight theme colors, Mermaid container

chore: bump version 0.1.13 → 0.1.14 across all files
2026-07-24 17:16:55 +08:00

740 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MetonaEditor Themes — 主题系统(增强版)
* @module themes
* @version 0.1.14
* @description 全局+实例级主题管理、CSS 变量隔离、外部主题跟随、主题继承
*
* v0.1.5 增强:
* - 修复 resolveTheme('auto') 始终解析为系统主题
* - 实例级 setTheme() / getTheme(),多实例独立主题
* - 实例级 themeChange 事件通知
* - 外部主题跟随:syncWithElement() / adoptFromParent() / watch()
* - 主题继承:registerTheme(name, { extends: 'light', ... })
* - 导出 CSS 变量:exportCSSVars() / getCSSVariable()
* - 主题悬停/聚焦状态变量支持
* - 应用主题到指定元素(不污染 :root)
*/
import { THEMES } from './constants.js';
import { prefersDark } from './utils.js';
// ============ 模块级状态(全局默认,向后兼容) ============
let globalCurrentTheme = 'auto';
let globalThemeListeners = new Set();
let systemThemeMediaQuery = null;
let systemThemeListener = null;
// ============ 系统主题 ============
/**
* 获取系统主题偏好
* @returns {'light'|'dark'}
*/
const getSystemTheme = () => {
if (typeof window === 'undefined') return 'light';
return prefersDark() ? 'dark' : 'light';
};
/**
* 监听系统主题变化(内部回调版)
* @param {(theme: 'light'|'dark') => void} callback
* @returns {() => void} 取消监听函数
*/
const _watchSystem = (callback) => {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e) => callback(e.matches ? 'dark' : 'light');
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
};
/**
* 监听系统主题变化(全局单例模式,向后兼容)
* 自动在 auto 模式下跟随系统主题切换
*/
const watchSystemTheme = () => {
if (typeof window === 'undefined' || !window.matchMedia) return;
// 清理旧监听
if (_sysWatchUnsub) { _sysWatchUnsub(); _sysWatchUnsub = null; }
_sysWatchUnsub = _watchSystem((sysTheme) => {
if (globalCurrentTheme === 'auto') {
applyTheme('auto');
}
});
};
// ============ 主题解析 ============
/**
* 解析主题名为实际主题值
* 修复:auto 始终解析为系统当前主题,不再被上次手动设置污染
* @param {string} theme - 主题名
* @returns {string} 解析后的主题名
*/
const resolveTheme = (theme) => {
if (!theme || theme === 'auto') {
return getSystemTheme();
}
return theme;
};
/**
* 获取主题配置对象
* @param {string} theme - 主题名
* @returns {Object} 主题配置
*/
const getThemeConfig = (theme) => {
const resolved = resolveTheme(theme);
return THEMES[resolved] || THEMES.light;
};
// ============ CSS 变量操作 ============
/**
* 将主题配置写入 CSS 变量
* @param {Object} config - 主题配置对象
* @param {HTMLElement} [target] - 目标元素(默认 documentElement
*/
const setThemeVariables = (config, target) => {
if (typeof document === 'undefined' || !config || typeof config !== 'object') return;
const root = target || document.documentElement;
const vars = {
'--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',
};
Object.entries(vars).forEach(([k, v]) => {
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);
};
/**
* 导出当前 CSS 变量值(从指定元素读取)
* @param {HTMLElement} [el] - 读取的元素(默认 documentElement
* @returns {Object} CSS 变量键值对
*/
const exportCSSVars = (el) => {
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 = {};
varNames.forEach((name) => {
const val = style.getPropertyValue(name).trim();
if (val) result[name] = val;
});
return result;
};
/**
* 获取单个 CSS 变量值
* @param {string} name - 变量名(自动补全 --md- 前缀)
* @param {HTMLElement} [el] - 读取的元素
* @returns {string} 变量值
*/
const getCSSVariable = (name, el) => {
const root = el || document.documentElement;
if (typeof document === 'undefined') return '';
const fullName = name.startsWith('--') ? name : `--md-${name}`;
return getComputedStyle(root).getPropertyValue(fullName).trim();
};
// ============ 主题切换与应用 ============
/**
* 应用主题到全局(向后兼容)
* @param {string} theme - 主题名
*/
const applyTheme = (theme) => {
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}`);
const config = getThemeConfig(theme);
setThemeVariables(config);
}
notifyGlobalListeners(theme, resolved);
};
/**
* 应用主题到指定元素(不改变全局状态)
* @param {string} theme - 主题名
* @param {HTMLElement} target - 目标 DOM 元素
*/
const applyThemeToElement = (theme, target) => {
if (!target || typeof document === 'undefined') return;
const resolved = resolveTheme(theme);
const config = getThemeConfig(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(config, target);
};
/**
* 切换主题并持久化
*/
const switchTheme = (theme) => {
applyTheme(theme);
saveTheme(theme);
};
/**
* 在 light/dark 间切换
*/
const toggleTheme = () => {
const resolved = getResolvedTheme();
switchTheme(resolved === 'dark' ? 'light' : 'dark');
};
/**
* 重置为自动主题
*/
const resetToAuto = () => {
switchTheme('auto');
};
/**
* 获取全局当前主题名
*/
const getCurrentTheme = () => globalCurrentTheme;
/**
* 获取解析后的全局主题
*/
const getResolvedTheme = () => resolveTheme(globalCurrentTheme);
// ============ 主题持久化 ============
const saveTheme = (theme) => {
if (typeof localStorage !== 'undefined') {
try { localStorage.setItem('metona-editor-theme', theme); } catch (_) {}
}
};
const loadTheme = () => {
if (typeof localStorage !== 'undefined') {
try { return localStorage.getItem('metona-editor-theme') || 'auto'; } catch (_) {}
}
return 'auto';
};
// ============ 全局监听 ============
const notifyGlobalListeners = (theme, resolved) => {
globalThemeListeners.forEach((fn) => {
try { fn(theme, resolved); } catch (e) { console.error('Theme listener error:', e); }
});
};
const addThemeListener = (fn) => {
globalThemeListeners.add(fn);
return () => globalThemeListeners.delete(fn);
};
const removeThemeListener = (fn) => {
globalThemeListeners.delete(fn);
};
const clearThemeListeners = () => {
globalThemeListeners.clear();
};
// ============ 系统主题监听 ============
let _sysWatchUnsub = null;
const startSystemWatch = () => {
watchSystemTheme(); // 使用兼容的全局单例
};
const stopSystemWatch = () => {
if (_sysWatchUnsub) { _sysWatchUnsub(); _sysWatchUnsub = null; }
};
// 保留旧 API 兼容
const unwatchSystemTheme = stopSystemWatch;
// ============ 初始化 ============
const initTheme = () => {
const savedTheme = loadTheme();
applyTheme(savedTheme);
startSystemWatch();
};
// ============ 外部主题跟随(v0.1.5 新增) ============
/**
* 跟随外部元素的主题
* 支持三种检测策略:
* 1. data-md-theme 属性(推荐,显式声明)
* 2. CSS 类名(如 theme-dark / theme-light
* 3. 自定义回调函数
*
* @param {Object} options
* @param {HTMLElement} [options.element] - 观察的元素(默认 documentElement
* @param {string} [options.attr='data-theme'] - 检测的属性名
* @param {string[]} [options.classMap] - 类名映射 { 'theme-dark': 'dark', 'theme-light': 'light' }
* @param {Function} [options.callback] - 自定义回调 (element) => themeName
* @param {Function} onThemeChange - 主题变化回调 (resolvedTheme) => void
* @returns {() => void} 取消跟随函数
*/
const followExternalTheme = (options, onThemeChange) => {
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') {
return () => {};
}
const {
element = document.documentElement,
attr = 'data-theme',
classMap = null,
callback = null,
} = options || {};
const detect = () => {
if (typeof callback === 'function') {
return callback(element);
}
// 策略1:属性检测
const attrVal = element.getAttribute(attr);
if (attrVal) return attrVal;
// 策略2classMap 检测
if (classMap) {
for (const [cls, theme] of Object.entries(classMap)) {
if (element.classList.contains(cls)) return theme;
}
}
// 策略3data-md-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();
};
/**
* 从父容器继承主题(读取 CSS 变量 + data-md-theme
* 适用场景:编辑器嵌入已有主题体系的应用时自动适配
*
* @param {HTMLElement} container - 编辑器容器元素
* @param {(theme: string) => void} onThemeDetected - 检测到主题的回调
* @returns {() => void} 取消跟随函数
*/
const adoptFromParent = (container, onThemeDetected) => {
if (typeof document === 'undefined' || !container) return () => {};
const detect = () => {
// 向上遍历 DOM 树寻找 data-md-theme 属性
let el = 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;
}
// 回退:读取父元素 computed style 中的 --md-bg 判断明暗
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 = container.parentElement;
while (target) {
observer.observe(target, { attributes: true, attributeFilter: ['class', 'data-md-theme', 'data-theme'] });
target = target.parentElement;
// 只观察向上3层,避免性能问题
if (target === document.documentElement) break;
}
return () => observer.disconnect();
};
/**
* 创建一个主题观察者,监听外部主题源变化
* @param {Function|string} source - 回调函数返回主题名,或 CSS 选择器
* @param {(theme: string) => void} onChange - 主题变化回调
* @returns {() => void} 取消观察
*/
const watch = (source, onChange) => {
if (typeof source === 'function') {
const tick = () => {
try {
const theme = source();
if (theme) onChange(theme);
} 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, attr: 'data-theme' }, onChange);
}
return () => {};
};
// ============ 自定义主题注册(v0.1.5: 支持继承) ============
/**
* 注册自定义主题
* @param {string} name - 主题名
* @param {Object} config - 主题配置
* @param {string} [config.extends] - 继承的基础主题名(如 'light'
*/
const registerTheme = (name, config = {}) => {
const baseName = config.extends || 'light';
const base = THEMES[baseName] || THEMES.light;
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,
};
};
const unregisterTheme = (name) => {
if (name === 'light' || name === 'dark' || name === 'auto' || name === 'warm') {
console.warn('Cannot unregister built-in theme:', name);
return;
}
delete THEMES[name];
};
const getAllThemes = () => ({ ...THEMES });
const getThemeNames = () => Object.keys(THEMES);
const hasTheme = (name) => name in THEMES;
// ============ 实例级主题管理(v0.1.5 新增) ============
/**
* 为编辑器实例创建隔离的主题上下文
* 每个上下文独立跟踪主题,变更时自动同步 CSS 变量到实例 DOM
*
* @param {Object} editor - 编辑器实例(需有 el / config 属性)
* @returns {Object} 实例主题 API
*/
const createInstanceTheme = (editor) => {
let instanceTheme = editor.config?.theme || globalCurrentTheme || 'auto';
/** 应用主题到当前实例的 DOM */
const apply = (theme) => {
instanceTheme = theme;
const resolved = resolveTheme(theme);
const config = getThemeConfig(theme);
if (editor.el) {
// 更新 wrapper 类名
['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);
// 设置实例级 CSS 变量(scope 到 wrapper
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) => apply(theme);
/** 切换 light/dark */
const toggle = () => {
const resolved = resolveTheme(instanceTheme);
return apply(resolved === 'dark' ? 'light' : 'dark');
};
/** 获取解析后的主题名 */
const getResolved = () => resolveTheme(instanceTheme);
/** 获取实例主题配置 */
const getConfig = () => getThemeConfig(instanceTheme);
/** 获取实例 CSS 变量导出 */
const getVars = () => {
if (editor.el) return exportCSSVars(editor.el);
return {};
};
/** 外部主题跟随:同步到外部元素 */
const syncWithElement = (element, opts = {}) => {
return followExternalTheme(
{ element, attr: opts.attr || 'data-theme', classMap: opts.classMap, callback: opts.callback },
(detected) => apply(detected),
);
};
/** 从父容器继承主题 */
const adopt = () => {
if (editor.container) {
return adoptFromParent(editor.container, (detected) => apply(detected));
}
return () => {};
};
/** 监听外部主题变化 */
const watchExternal = (source) => {
return watch(source, (theme) => apply(theme));
};
// 初始应用
apply(instanceTheme);
return {
apply,
get,
set,
toggle,
getResolved,
getConfig,
getVars,
syncWithElement,
adopt,
watch: watchExternal,
};
};
/**
* 创建独立主题管理器(工厂函数,向后兼容)
* @returns {Object} 主题管理器
*/
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,
});
// ============ 预设主题 ============
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 },
};
// 初始化:确保 THEMES 中有预设
Object.entries(presetThemes).forEach(([name, theme]) => {
if (name !== 'auto' && theme.config !== 'auto') {
THEMES[name] = theme.config;
}
});
// ============ 工具代理层 ============
const themeUtils = {
// 系统
getSystemTheme,
resolveTheme,
getThemeConfig,
watchSystemTheme,
unwatchSystemTheme,
// 全局主题
applyTheme,
getCurrentTheme,
getResolvedTheme,
switchTheme,
toggleTheme,
resetToAuto,
initTheme,
// 持久化
saveTheme,
loadTheme,
// 监听
addThemeListener,
removeThemeListener,
clearThemeListeners,
// 注册
registerTheme,
unregisterTheme,
getAllThemes,
getThemeNames,
hasTheme,
// CSS 变量
setThemeVariables,
exportCSSVars,
getCSSVariable,
applyThemeToElement,
// 外部跟随(v0.1.5
followExternalTheme,
adoptFromParent,
watch,
// 实例主题(v0.1.5
createInstanceTheme,
};
// ============ 具名导出 ============
export {
themeUtils,
themeUtils as default,
getSystemTheme,
resolveTheme,
getThemeConfig,
applyTheme,
getCurrentTheme,
getResolvedTheme,
switchTheme,
toggleTheme,
resetToAuto,
saveTheme,
loadTheme,
initTheme,
watchSystemTheme,
unwatchSystemTheme,
addThemeListener,
removeThemeListener,
clearThemeListeners,
registerTheme,
unregisterTheme,
getAllThemes,
getThemeNames,
hasTheme,
setThemeVariables,
exportCSSVars,
getCSSVariable,
applyThemeToElement,
followExternalTheme,
adoptFromParent,
watch,
createInstanceTheme,
createThemeManager,
presetThemes,
};
// 向后兼容别名
const getTheme = resolveTheme;
export { getTheme };