release: v0.1.5 — theme system usability, extensibility, external theme following
feat(themes): comprehensive theme system enhancement
- Fix resolveTheme('auto') always resolves to system theme (no stale override)
- Add per-instance theme isolation via createInstanceTheme()
- Add MarkdownEditor.setTheme()/getTheme()/getThemeContext() instance methods
- Add 'themeChange' instance event with { theme, resolved, config } payload
- Multi-instance independent themes (no cross-contamination)
feat(themes): external theme following (v0.1.5)
- followExternalTheme() — follow theme via data attr, CSS class, or callback
- adoptFromParent() — inherit theme from parent container
- watch() — watch external theme source (function or selector)
feat(themes): extensibility (v0.1.5)
- registerTheme() supports 'extends' option for theme inheritance
- exportCSSVars() — export all CSS variable values from element
- getCSSVariable() — query single CSS variable
- applyThemeToElement() — apply theme to any DOM element
chore: bump version 0.1.4 → 0.1.5 across all files
test(themes): 30 new tests covering all v0.1.5 theme APIs (499 total)
This commit is contained in:
@@ -2,9 +2,9 @@
|
||||
|
||||
> 轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用,中文优先。
|
||||
|
||||
[](https://www.npmjs.com/package/@metona-team/metona-editor)
|
||||
[](https://www.npmjs.com/package/@metona-team/metona-editor)
|
||||
[](./LICENSE)
|
||||
[](./tests)
|
||||
[](./tests)
|
||||
[](./tests)
|
||||
|
||||
---
|
||||
@@ -19,7 +19,7 @@
|
||||
- **安全** — 内置 XSS 防护(`javascript:`/`vbscript:`/`file:` 协议过滤),HTML 转义,`safeUrl` 净化
|
||||
- **内置解析器** — 自研 CommonMark 子集 + GFM 扩展(表格含列对齐、任务列表、上下标、数学公式、脚注、嵌套列表、emoji 短码等),可整体替换
|
||||
- **三模式视图** — edit / split / preview 自由切换,分屏拖拽调整比例,滚动同步,模式切换保持滚动位置
|
||||
- **主题系统** — light / dark / auto / warm 四套预设,CSS 变量定制,跟随系统主题,@media print 打印样式
|
||||
- **主题系统** — light / dark / auto / warm 四套预设,CSS 变量定制,实例级主题隔离,外部主题跟随(syncWithElement / adoptFromParent),主题继承(extends),跟随系统主题,@media print 打印样式
|
||||
- **国际化** — zh-CN / en-US 完整翻译,RTL 支持,`Intl` 数字/货币/日期格式化
|
||||
- **历史栈** — 撤销/重做,防抖合并(可配置延迟),可配置上限
|
||||
- **只读模式** — `readOnly` 配置 / `setReadOnly()` API,适用于预览/展示场景
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@metona-team/metona-editor",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"description": "轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用。",
|
||||
"type": "module",
|
||||
"main": "dist/metona-editor.js",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Animations - 动画管理
|
||||
* @module animations
|
||||
* @version 0.1.4
|
||||
* @version 0.1.5
|
||||
* @description 动画注册与管理(当前为 CSS 动画元数据管理,供未来扩展如 Toast/通知组件的进出场动画使用;
|
||||
* 内置 7 种预设动画曲线配置;编辑器核心当前未直接调用此模块)
|
||||
*/
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Constants - 常量定义
|
||||
* @module constants
|
||||
* @version 0.1.4
|
||||
* @version 0.1.5
|
||||
* @description 默认配置、主题、动画、工具栏等常量
|
||||
*/
|
||||
|
||||
|
||||
+45
-8
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Core - 编辑器核心
|
||||
* @module core
|
||||
* @version 0.1.4
|
||||
* @version 0.1.5
|
||||
* @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,7 @@ import { injectStyles } from './styles.js';
|
||||
import { t as i18nT, getCurrentLocale, getLocaleDirection } from './i18n.js';
|
||||
import { parseMarkdown } from './parser.js';
|
||||
import { presetPlugins } from './plugins.js';
|
||||
import { getTheme, applyTheme, saveTheme, getThemeConfig, setThemeVariables } from './themes.js';
|
||||
import { resolveTheme, getThemeConfig, setThemeVariables, createInstanceTheme } from './themes.js';
|
||||
|
||||
const MODES = ['edit', 'split', 'preview'];
|
||||
|
||||
@@ -20,7 +20,7 @@ const MODES = ['edit', 'split', 'preview'];
|
||||
*/
|
||||
const resolveThemeName = (theme) => {
|
||||
if (theme && theme !== 'auto') return theme;
|
||||
return getTheme('auto');
|
||||
return resolveTheme('auto');
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -99,13 +99,18 @@ class MarkdownEditor {
|
||||
injectStyles();
|
||||
this._buildDOM();
|
||||
|
||||
// 主题同步:用户显式传 theme 时覆盖全局 CSS 变量并持久化,否则尊重 localStorage
|
||||
// 实例级主题管理(v0.1.5: 隔离 CSS 变量到 wrapper,支持独立主题)
|
||||
this._themeCtx = createInstanceTheme(this);
|
||||
|
||||
// 向后兼容:用户显式传 theme 时同步到全局并持久化
|
||||
if (options.theme) {
|
||||
applyTheme(this.config.theme);
|
||||
saveTheme(this.config.theme);
|
||||
// 全局同步(保留原有行为)
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-md-theme', resolveTheme(options.theme));
|
||||
}
|
||||
}
|
||||
|
||||
// 将 CSS 变量限定到当前实例的 wrapper 元素,避免污染全局样式
|
||||
// 确保 CSS 变量作用到实例 wrapper
|
||||
const resolvedThemeConfig = getThemeConfig(this.config.theme);
|
||||
setThemeVariables(resolvedThemeConfig, this.el);
|
||||
|
||||
@@ -981,6 +986,38 @@ class MarkdownEditor {
|
||||
|
||||
isDestroyed() { return this._destroyed; }
|
||||
|
||||
/**
|
||||
* 设置实例主题(v0.1.5: 实例隔离,不影响其他编辑器)
|
||||
* @param {string} theme - 主题名
|
||||
* @returns {MarkdownEditor}
|
||||
*/
|
||||
setTheme(theme) {
|
||||
if (!theme) return this;
|
||||
this.config.theme = theme;
|
||||
if (this._themeCtx) {
|
||||
this._themeCtx.set(theme);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实例当前主题名
|
||||
* @returns {string}
|
||||
*/
|
||||
getTheme() {
|
||||
if (this._themeCtx) return this._themeCtx.get();
|
||||
return this.config.theme || 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实例主题上下文(高级 API)
|
||||
* 提供 syncWithElement / adopt / watch 等外部跟随能力
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
getThemeContext() {
|
||||
return this._themeCtx || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取编辑器状态
|
||||
*/
|
||||
@@ -988,7 +1025,7 @@ class MarkdownEditor {
|
||||
return {
|
||||
id: this.id,
|
||||
mode: this._mode,
|
||||
theme: this.config.theme,
|
||||
theme: this.getTheme(),
|
||||
locale: getCurrentLocale(),
|
||||
fullscreen: this._fullscreen,
|
||||
readOnly: this.config.readOnly || false,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor i18n - 国际化管理
|
||||
* @module i18n
|
||||
* @version 0.1.4
|
||||
* @version 0.1.5
|
||||
* @description 多语言支持、语言切换和翻译管理
|
||||
*/
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Icons — 工具栏图标SVG定义
|
||||
* @module icons
|
||||
* @version 0.1.4
|
||||
* @version 0.1.5
|
||||
* @description 工具栏格式化按钮 SVG 图标
|
||||
*/
|
||||
|
||||
|
||||
+15
-3
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor - 轻量级 Markdown Editor 库
|
||||
* @module metona-editor
|
||||
* @version 0.1.4
|
||||
* @version 0.1.5
|
||||
* @author thzxx
|
||||
* @description 现代化、零依赖、易扩展、易维护、易使用的 Markdown 编辑器库。单文件,开箱即用。
|
||||
* @license MIT
|
||||
@@ -22,14 +22,14 @@
|
||||
|
||||
import { MarkdownEditor } from './core.js';
|
||||
import { parseMarkdown, safeUrl, slugify, clearRenderCache } from './parser.js';
|
||||
import { themeUtils } from './themes.js';
|
||||
import { themeUtils, exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme } from './themes.js';
|
||||
import { i18nUtils } from './i18n.js';
|
||||
import { pluginUtils, presetPlugins } from './plugins.js';
|
||||
import { animationUtils } from './animations.js';
|
||||
import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants.js';
|
||||
|
||||
// 版本信息
|
||||
const VERSION = '0.1.4';
|
||||
const VERSION = '0.1.5';
|
||||
|
||||
/**
|
||||
* 全局默认插件队列
|
||||
@@ -202,6 +202,13 @@ const api = {
|
||||
plugins: pluginUtils,
|
||||
presetPlugins,
|
||||
|
||||
// 主题工具(v0.1.5 新增)
|
||||
exportCSSVars,
|
||||
getCSSVariable,
|
||||
followExternalTheme,
|
||||
adoptFromParent,
|
||||
createInstanceTheme,
|
||||
|
||||
// 常量
|
||||
DEFAULTS,
|
||||
ICONS,
|
||||
@@ -240,6 +247,11 @@ export {
|
||||
pluginUtils,
|
||||
presetPlugins,
|
||||
animationUtils,
|
||||
exportCSSVars,
|
||||
getCSSVariable,
|
||||
followExternalTheme,
|
||||
adoptFromParent,
|
||||
createInstanceTheme,
|
||||
DEFAULTS,
|
||||
ICONS,
|
||||
THEMES,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Locales — 国际化翻译数据
|
||||
* @module locales
|
||||
* @version 0.1.4
|
||||
* @version 0.1.5
|
||||
* @description 内置 zh-CN / en-US 完整翻译
|
||||
*/
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Parser - 轻量 Markdown 解析器(增强版)
|
||||
* @module parser
|
||||
* @version 0.1.4
|
||||
* @version 0.1.5
|
||||
* @description 自研零依赖 Markdown 解析器,覆盖 CommonMark 子集 + GFM 扩展
|
||||
*
|
||||
* v0.1.4 增强:
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Plugins - 插件系统
|
||||
* @module plugins
|
||||
* @version 0.1.4
|
||||
* @version 0.1.5
|
||||
* @description 插件管理器 + 预设插件(autoSave / exportTool / searchReplace)
|
||||
*
|
||||
* 插件约定:
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Styles - 编辑器样式
|
||||
* @module styles
|
||||
* @version 0.1.4
|
||||
* @version 0.1.5
|
||||
* @description 编辑器 UI 样式注入与管理
|
||||
*/
|
||||
|
||||
|
||||
+600
-251
@@ -1,305 +1,609 @@
|
||||
/**
|
||||
* MetonaEditor Themes - 主题管理
|
||||
* MetonaEditor Themes — 主题系统(增强版)
|
||||
* @module themes
|
||||
* @version 0.1.4
|
||||
* @description 主题系统、自定义主题和主题切换
|
||||
* @version 0.1.5
|
||||
* @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 currentTheme = 'auto';
|
||||
let themeListeners = new Set();
|
||||
// ============ 模块级状态(全局默认,向后兼容) ============
|
||||
|
||||
let globalCurrentTheme = 'auto';
|
||||
let globalThemeListeners = new Set();
|
||||
let systemThemeMediaQuery = null;
|
||||
let systemThemeListener = null;
|
||||
|
||||
// ============ 系统主题 ============
|
||||
|
||||
/**
|
||||
* 获取系统主题
|
||||
* 获取系统主题偏好
|
||||
* @returns {'light'|'dark'}
|
||||
*/
|
||||
export const getSystemTheme = () => {
|
||||
const getSystemTheme = () => {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return prefersDark() ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取主题(别名)
|
||||
* 监听系统主题变化(内部回调版)
|
||||
* @param {(theme: 'light'|'dark') => void} callback
|
||||
* @returns {() => void} 取消监听函数
|
||||
*/
|
||||
export const getTheme = (theme) => {
|
||||
return resolveTheme(theme);
|
||||
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 模式下跟随系统主题切换
|
||||
*/
|
||||
export const resolveTheme = (theme) => {
|
||||
if (theme === 'auto') {
|
||||
if (currentTheme && currentTheme !== 'auto') {
|
||||
return currentTheme;
|
||||
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} 主题配置
|
||||
*/
|
||||
export const getThemeConfig = (theme) => {
|
||||
const getThemeConfig = (theme) => {
|
||||
const resolved = resolveTheme(theme);
|
||||
return THEMES[resolved] || THEMES.light;
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用主题
|
||||
*/
|
||||
export const applyTheme = (theme) => {
|
||||
currentTheme = 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');
|
||||
document.documentElement.classList.add(`md-theme-${resolved}`);
|
||||
|
||||
const config = getThemeConfig(theme);
|
||||
setThemeVariables(config);
|
||||
}
|
||||
|
||||
notifyThemeListeners(theme, resolved);
|
||||
};
|
||||
// ============ CSS 变量操作 ============
|
||||
|
||||
/**
|
||||
* 设置主题CSS变量
|
||||
* 将主题配置写入 CSS 变量
|
||||
* @param {Object} config - 主题配置对象
|
||||
* @param {HTMLElement} [target] - 目标元素,不传则设置到 documentElement(全局默认)
|
||||
* @param {HTMLElement} [target] - 目标元素(默认 documentElement)
|
||||
*/
|
||||
export const setThemeVariables = (config, target) => {
|
||||
const setThemeVariables = (config, target) => {
|
||||
if (typeof document === 'undefined' || !config || typeof config !== 'object') return;
|
||||
|
||||
const root = target || document.documentElement;
|
||||
// 基础变量(保留用于全局与组件回退)
|
||||
root.style.setProperty('--md-bg', config.bg);
|
||||
root.style.setProperty('--md-text', config.text);
|
||||
root.style.setProperty('--md-border', config.border);
|
||||
root.style.setProperty('--md-shadow', config.shadow);
|
||||
root.style.setProperty('--md-hover-shadow', config.hoverShadow);
|
||||
// 编辑器专属变量
|
||||
root.style.setProperty('--md-toolbar-bg', config.toolbarBg || config.bg);
|
||||
root.style.setProperty('--md-textarea-bg', config.textareaBg || config.bg);
|
||||
root.style.setProperty('--md-preview-bg', config.previewBg || config.bg);
|
||||
root.style.setProperty('--md-code-bg', config.codeBg || 'rgba(127,127,127,0.1)');
|
||||
root.style.setProperty('--md-code-text', config.codeText || config.text);
|
||||
root.style.setProperty('--md-accent', config.accent || '#3b82f6');
|
||||
root.style.setProperty('--md-muted', config.muted || '#9ca3af');
|
||||
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 变量键值对
|
||||
*/
|
||||
export const getCurrentTheme = () => {
|
||||
return currentTheme;
|
||||
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} 变量值
|
||||
*/
|
||||
export const getResolvedTheme = () => {
|
||||
return resolveTheme(currentTheme);
|
||||
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 元素
|
||||
*/
|
||||
export const switchTheme = (theme) => {
|
||||
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 间切换
|
||||
*/
|
||||
export const toggleTheme = () => {
|
||||
const toggleTheme = () => {
|
||||
const resolved = getResolvedTheme();
|
||||
const newTheme = resolved === 'dark' ? 'light' : 'dark';
|
||||
switchTheme(newTheme);
|
||||
switchTheme(resolved === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置为自动主题
|
||||
*/
|
||||
export const resetToAuto = () => {
|
||||
const resetToAuto = () => {
|
||||
switchTheme('auto');
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存主题到本地存储
|
||||
* 获取全局当前主题名
|
||||
*/
|
||||
export const saveTheme = (theme) => {
|
||||
const getCurrentTheme = () => globalCurrentTheme;
|
||||
|
||||
/**
|
||||
* 获取解析后的全局主题
|
||||
*/
|
||||
const getResolvedTheme = () => resolveTheme(globalCurrentTheme);
|
||||
|
||||
// ============ 主题持久化 ============
|
||||
|
||||
const saveTheme = (theme) => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem('metona-editor-theme', theme);
|
||||
} catch (e) {
|
||||
console.warn('Failed to save theme:', e);
|
||||
}
|
||||
try { localStorage.setItem('metona-editor-theme', theme); } catch (_) {}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从本地存储加载主题
|
||||
*/
|
||||
export const loadTheme = () => {
|
||||
const loadTheme = () => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
return localStorage.getItem('metona-editor-theme') || 'auto';
|
||||
} catch (e) {
|
||||
console.warn('Failed to load theme:', e);
|
||||
}
|
||||
try { return localStorage.getItem('metona-editor-theme') || 'auto'; } catch (_) {}
|
||||
}
|
||||
return 'auto';
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化主题系统
|
||||
*/
|
||||
export const initTheme = () => {
|
||||
const savedTheme = loadTheme();
|
||||
applyTheme(savedTheme);
|
||||
watchSystemTheme();
|
||||
};
|
||||
// ============ 全局监听 ============
|
||||
|
||||
/**
|
||||
* 监听系统主题变化
|
||||
*/
|
||||
export const watchSystemTheme = () => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||
|
||||
if (systemThemeMediaQuery && systemThemeListener) {
|
||||
systemThemeMediaQuery.removeEventListener('change', systemThemeListener);
|
||||
}
|
||||
|
||||
systemThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
systemThemeListener = (e) => {
|
||||
if (currentTheme === 'auto') {
|
||||
applyTheme('auto');
|
||||
}
|
||||
};
|
||||
|
||||
systemThemeMediaQuery.addEventListener('change', systemThemeListener);
|
||||
};
|
||||
|
||||
/**
|
||||
* 停止监听系统主题变化
|
||||
*/
|
||||
export const unwatchSystemTheme = () => {
|
||||
if (systemThemeMediaQuery && systemThemeListener) {
|
||||
systemThemeMediaQuery.removeEventListener('change', systemThemeListener);
|
||||
systemThemeMediaQuery = null;
|
||||
systemThemeListener = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加主题监听器
|
||||
*/
|
||||
export const addThemeListener = (listener) => {
|
||||
themeListeners.add(listener);
|
||||
|
||||
return () => {
|
||||
themeListeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除主题监听器
|
||||
*/
|
||||
export const removeThemeListener = (listener) => {
|
||||
themeListeners.delete(listener);
|
||||
};
|
||||
|
||||
/**
|
||||
* 通知主题监听器
|
||||
*/
|
||||
const notifyThemeListeners = (theme, resolved) => {
|
||||
themeListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(theme, resolved);
|
||||
} catch (e) {
|
||||
console.error('Theme listener error:', e);
|
||||
}
|
||||
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} 取消跟随函数
|
||||
*/
|
||||
export const clearThemeListeners = () => {
|
||||
themeListeners.clear();
|
||||
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;
|
||||
// 策略2:classMap 检测
|
||||
if (classMap) {
|
||||
for (const [cls, theme] of Object.entries(classMap)) {
|
||||
if (element.classList.contains(cls)) return theme;
|
||||
}
|
||||
}
|
||||
// 策略3:data-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} 取消跟随函数
|
||||
*/
|
||||
export const registerTheme = (name, config) => {
|
||||
const base = THEMES.light;
|
||||
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 || config.bg || base.toolbarBg,
|
||||
textareaBg: config.textareaBg || config.bg || base.textareaBg,
|
||||
previewBg: config.previewBg || config.bg || base.previewBg,
|
||||
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,
|
||||
closeHoverBg: config.closeHoverBg,
|
||||
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} 主题管理器
|
||||
*/
|
||||
export const unregisterTheme = (name) => {
|
||||
if (name === 'light' || name === 'dark' || name === 'auto') {
|
||||
console.warn('Cannot unregister built-in theme:', name);
|
||||
return;
|
||||
}
|
||||
|
||||
delete THEMES[name];
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有主题
|
||||
*/
|
||||
export const getAllThemes = () => {
|
||||
return { ...THEMES };
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取主题名称列表
|
||||
*/
|
||||
export const getThemeNames = () => {
|
||||
return Object.keys(THEMES);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查主题是否存在
|
||||
*/
|
||||
export const hasTheme = (name) => {
|
||||
return name in THEMES;
|
||||
};
|
||||
|
||||
/**
|
||||
* 主题工具 — 代理层
|
||||
*/
|
||||
export const themeUtils = {
|
||||
const createThemeManager = () => ({
|
||||
getSystemTheme,
|
||||
resolveTheme,
|
||||
getThemeConfig,
|
||||
@@ -322,69 +626,114 @@ export const themeUtils = {
|
||||
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 },
|
||||
};
|
||||
|
||||
/**
|
||||
* 预设主题
|
||||
*/
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
// 注册预设主题
|
||||
// 初始化:确保 THEMES 中有预设
|
||||
Object.entries(presetThemes).forEach(([name, theme]) => {
|
||||
if (name !== 'auto' && theme.config !== 'auto') {
|
||||
THEMES[name] = theme.config;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建主题管理器
|
||||
*/
|
||||
export const createThemeManager = () => {
|
||||
return {
|
||||
getSystemTheme,
|
||||
resolveTheme,
|
||||
getThemeConfig,
|
||||
applyTheme,
|
||||
getCurrentTheme,
|
||||
getResolvedTheme,
|
||||
switchTheme,
|
||||
toggleTheme,
|
||||
resetToAuto,
|
||||
initTheme,
|
||||
watchSystemTheme,
|
||||
unwatchSystemTheme,
|
||||
addThemeListener,
|
||||
removeThemeListener,
|
||||
clearThemeListeners,
|
||||
registerTheme,
|
||||
unregisterTheme,
|
||||
getAllThemes,
|
||||
getThemeNames,
|
||||
hasTheme,
|
||||
saveTheme,
|
||||
loadTheme,
|
||||
};
|
||||
// ============ 工具代理层 ============
|
||||
|
||||
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 as default };
|
||||
// ============ 具名导出 ============
|
||||
|
||||
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 };
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MetonaEditor Utils - 工具函数
|
||||
* @module utils
|
||||
* @version 0.1.4
|
||||
* @version 0.1.5
|
||||
* @description 通用工具函数集合
|
||||
*/
|
||||
|
||||
|
||||
@@ -34,8 +34,16 @@ import {
|
||||
setThemeVariables,
|
||||
presetThemes,
|
||||
createThemeManager,
|
||||
exportCSSVars,
|
||||
getCSSVariable,
|
||||
applyThemeToElement,
|
||||
followExternalTheme,
|
||||
adoptFromParent,
|
||||
watch,
|
||||
createInstanceTheme,
|
||||
} from '../src/themes.js';
|
||||
import { THEMES } from '../src/constants.js';
|
||||
import { MarkdownEditor } from '../src/core.js';
|
||||
|
||||
// 确保 localStorage 存在(jsdom 兼容)
|
||||
if (typeof global.localStorage === 'undefined') {
|
||||
@@ -431,3 +439,384 @@ describe('createThemeManager', () => {
|
||||
expect(m.getCurrentTheme()).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.1.5 新增测试 ============
|
||||
|
||||
describe('v0.1.5 resolveTheme auto 修复', () => {
|
||||
beforeEach(() => {
|
||||
clearThemeListeners();
|
||||
resetToAuto();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test('auto 始终解析为系统主题,不受上次手动设置污染', () => {
|
||||
switchTheme('dark');
|
||||
expect(getCurrentTheme()).toBe('dark');
|
||||
// 即使手动设为 dark,resolveTheme('auto') 仍应返回系统主题
|
||||
const sys = getSystemTheme();
|
||||
expect(resolveTheme('auto')).toBe(sys);
|
||||
});
|
||||
|
||||
test('resolveTheme 传入具体主题名原样返回', () => {
|
||||
expect(resolveTheme('light')).toBe('light');
|
||||
expect(resolveTheme('dark')).toBe('dark');
|
||||
expect(resolveTheme('warm')).toBe('warm');
|
||||
});
|
||||
|
||||
test('resolveTheme 无参/空值返回系统主题', () => {
|
||||
const sys = getSystemTheme();
|
||||
expect(resolveTheme()).toBe(sys);
|
||||
expect(resolveTheme(null)).toBe(sys);
|
||||
expect(resolveTheme('')).toBe(sys);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 exportCSSVars / getCSSVariable', () => {
|
||||
test('exportCSSVars 返回 CSS 变量对象', () => {
|
||||
applyTheme('light');
|
||||
const vars = exportCSSVars();
|
||||
expect(vars).toHaveProperty('--md-bg');
|
||||
expect(vars).toHaveProperty('--md-accent');
|
||||
});
|
||||
|
||||
test('exportCSSVars 从指定元素读取', () => {
|
||||
const div = document.createElement('div');
|
||||
div.style.setProperty('--md-bg', '#fff');
|
||||
document.body.appendChild(div);
|
||||
const vars = exportCSSVars(div);
|
||||
expect(vars['--md-bg']).toBe('#fff');
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('getCSSVariable 获取单个变量', () => {
|
||||
applyTheme('light');
|
||||
const bg = getCSSVariable('--md-bg');
|
||||
expect(typeof bg).toBe('string');
|
||||
expect(bg.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('getCSSVariable 自动补全 --md- 前缀', () => {
|
||||
applyTheme('light');
|
||||
const accent = getCSSVariable('accent');
|
||||
expect(typeof accent).toBe('string');
|
||||
expect(accent.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 applyThemeToElement', () => {
|
||||
test('应用主题到指定元素不改变全局状态', () => {
|
||||
switchTheme('light');
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
|
||||
applyThemeToElement('dark', div);
|
||||
expect(div.getAttribute('data-md-theme')).toBe('dark');
|
||||
// 全局主题不变
|
||||
expect(getCurrentTheme()).toBe('light');
|
||||
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('应用 auto 主题正确解析', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
applyThemeToElement('auto', div);
|
||||
const resolved = resolveTheme('auto');
|
||||
expect(div.getAttribute('data-md-theme')).toBe(resolved);
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 主题继承 registerTheme extends', () => {
|
||||
test('extends 继承基础主题', () => {
|
||||
registerTheme('ocean', {
|
||||
extends: 'dark',
|
||||
accent: '#00ddff',
|
||||
});
|
||||
expect(hasTheme('ocean')).toBe(true);
|
||||
const cfg = getThemeConfig('ocean');
|
||||
expect(cfg.accent).toBe('#00ddff');
|
||||
// 从 dark 继承
|
||||
expect(cfg.bg).toBe(THEMES.dark.bg);
|
||||
expect(cfg.text).toBe(THEMES.dark.text);
|
||||
unregisterTheme('ocean');
|
||||
});
|
||||
|
||||
test('默认 extends light', () => {
|
||||
registerTheme('minimal', { accent: '#ff0000' });
|
||||
const cfg = getThemeConfig('minimal');
|
||||
expect(cfg.bg).toBe(THEMES.light.bg);
|
||||
unregisterTheme('minimal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 createInstanceTheme', () => {
|
||||
test('创建实例主题上下文', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
const mockEditor = {
|
||||
el: div,
|
||||
config: { theme: 'dark' },
|
||||
_emit: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
};
|
||||
const ctx = createInstanceTheme(mockEditor);
|
||||
expect(ctx.get()).toBe('dark');
|
||||
expect(ctx.getResolved()).toBe('dark');
|
||||
expect(typeof ctx.set).toBe('function');
|
||||
expect(typeof ctx.toggle).toBe('function');
|
||||
expect(typeof ctx.getConfig).toBe('function');
|
||||
expect(typeof ctx.getVars).toBe('function');
|
||||
expect(typeof ctx.syncWithElement).toBe('function');
|
||||
expect(typeof ctx.adopt).toBe('function');
|
||||
expect(typeof ctx.watch).toBe('function');
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('实例主题 set 触发 _emit themeChange', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
const mockEditor = {
|
||||
el: div,
|
||||
config: { theme: 'light' },
|
||||
_emit: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
};
|
||||
const ctx = createInstanceTheme(mockEditor);
|
||||
ctx.set('dark');
|
||||
expect(mockEditor._emit).toHaveBeenCalledWith('themeChange', expect.objectContaining({
|
||||
theme: 'dark',
|
||||
resolved: 'dark',
|
||||
}));
|
||||
expect(mockEditor.refresh).toHaveBeenCalled();
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('实例主题 toggle 切换 light/dark', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
const mockEditor = {
|
||||
el: div,
|
||||
config: { theme: 'light' },
|
||||
_emit: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
};
|
||||
const ctx = createInstanceTheme(mockEditor);
|
||||
ctx.toggle();
|
||||
expect(ctx.get()).toBe('dark');
|
||||
ctx.toggle();
|
||||
expect(ctx.get()).toBe('light');
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('getConfig 返回主题配置对象', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
const mockEditor = {
|
||||
el: div,
|
||||
config: { theme: 'light' },
|
||||
_emit: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
};
|
||||
const ctx = createInstanceTheme(mockEditor);
|
||||
const cfg = ctx.getConfig();
|
||||
expect(cfg).toHaveProperty('bg');
|
||||
expect(cfg).toHaveProperty('accent');
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('getVars 返回实例 CSS 变量', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
const mockEditor = {
|
||||
el: div,
|
||||
config: { theme: 'light' },
|
||||
_emit: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
};
|
||||
const ctx = createInstanceTheme(mockEditor);
|
||||
applyThemeToElement('light', div);
|
||||
const vars = ctx.getVars();
|
||||
expect(vars).toHaveProperty('--md-bg');
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 followExternalTheme', () => {
|
||||
test('通过 data-theme 属性跟随', () => {
|
||||
const div = document.createElement('div');
|
||||
div.setAttribute('data-theme', 'dark');
|
||||
document.body.appendChild(div);
|
||||
|
||||
let detected = null;
|
||||
const unsub = followExternalTheme({ element: div }, (theme) => { detected = theme; });
|
||||
expect(detected).toBe('dark');
|
||||
|
||||
div.setAttribute('data-theme', 'light');
|
||||
// 手动触发 MutationObserver(jsdom 中需手动 dispatch)
|
||||
const observer = new MutationObserver(() => {});
|
||||
// 注:jsdom 环境 MutationObserver 行为受限,此处只验证初始化回调
|
||||
unsub();
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('通过 classMap 跟随', () => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'theme-dark';
|
||||
document.body.appendChild(div);
|
||||
|
||||
let detected = null;
|
||||
followExternalTheme(
|
||||
{ element: div, classMap: { 'theme-dark': 'dark', 'theme-light': 'light' } },
|
||||
(theme) => { detected = theme; },
|
||||
);
|
||||
expect(detected).toBe('dark');
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('通过 callback 跟随', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
|
||||
let detected = null;
|
||||
followExternalTheme(
|
||||
{ element: div, callback: () => 'warm' },
|
||||
(theme) => { detected = theme; },
|
||||
);
|
||||
expect(detected).toBe('warm');
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('返回取消跟随函数', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
const unsub = followExternalTheme({ element: div }, () => {});
|
||||
expect(typeof unsub).toBe('function');
|
||||
unsub();
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 adoptFromParent', () => {
|
||||
test('从父容器 data-md-theme 继承', () => {
|
||||
const parent = document.createElement('div');
|
||||
parent.setAttribute('data-md-theme', 'dark');
|
||||
const child = document.createElement('div');
|
||||
parent.appendChild(child);
|
||||
document.body.appendChild(parent);
|
||||
|
||||
let detected = null;
|
||||
const unsub = adoptFromParent(child, (theme) => { detected = theme; });
|
||||
expect(detected).toBe('dark');
|
||||
unsub();
|
||||
document.body.removeChild(parent);
|
||||
});
|
||||
|
||||
test('从父容器 class 继承', () => {
|
||||
const parent = document.createElement('div');
|
||||
parent.className = 'dark';
|
||||
const child = document.createElement('div');
|
||||
parent.appendChild(child);
|
||||
document.body.appendChild(parent);
|
||||
|
||||
let detected = null;
|
||||
adoptFromParent(child, (theme) => { detected = theme; });
|
||||
expect(detected).toBe('dark');
|
||||
document.body.removeChild(parent);
|
||||
});
|
||||
|
||||
test('容器无父元素不抛错', () => {
|
||||
const orphan = document.createElement('div');
|
||||
let detected = null;
|
||||
expect(() => adoptFromParent(orphan, (t) => { detected = t; })).not.toThrow();
|
||||
});
|
||||
|
||||
test('返回取消函数', () => {
|
||||
const parent = document.createElement('div');
|
||||
const child = document.createElement('div');
|
||||
parent.appendChild(child);
|
||||
document.body.appendChild(parent);
|
||||
const unsub = adoptFromParent(child, () => {});
|
||||
expect(typeof unsub).toBe('function');
|
||||
unsub();
|
||||
document.body.removeChild(parent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 watch 外部主题源', () => {
|
||||
test('函数源:回调被调用', () => {
|
||||
let detected = null;
|
||||
const unsub = watch(() => 'dark', (theme) => { detected = theme; });
|
||||
expect(detected).toBe('dark');
|
||||
unsub();
|
||||
});
|
||||
|
||||
test('返回取消函数', () => {
|
||||
const unsub = watch(() => 'light', () => {});
|
||||
expect(typeof unsub).toBe('function');
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 MarkdownEditor 实例主题 API', () => {
|
||||
test('setTheme / getTheme 实例方法', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { theme: 'light' });
|
||||
|
||||
expect(ed.getTheme()).toBe('light');
|
||||
ed.setTheme('dark');
|
||||
expect(ed.getTheme()).toBe('dark');
|
||||
// el 类名应更新
|
||||
expect(ed.el.className).toContain('me-theme-dark');
|
||||
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('getThemeContext 返回实例主题上下文', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
|
||||
const ctx = ed.getThemeContext();
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(typeof ctx.set).toBe('function');
|
||||
expect(typeof ctx.get).toBe('function');
|
||||
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('多实例独立主题互不干扰', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c1 = document.createElement('div');
|
||||
const c2 = document.createElement('div');
|
||||
document.body.appendChild(c1);
|
||||
document.body.appendChild(c2);
|
||||
const ed1 = new MarkdownEditor(c1, { theme: 'light' });
|
||||
const ed2 = new MarkdownEditor(c2, { theme: 'dark' });
|
||||
|
||||
expect(ed1.getTheme()).toBe('light');
|
||||
expect(ed2.getTheme()).toBe('dark');
|
||||
|
||||
ed1.setTheme('warm');
|
||||
expect(ed1.getTheme()).toBe('warm');
|
||||
expect(ed2.getTheme()).toBe('dark'); // ed2 不受影响
|
||||
|
||||
ed1.destroy();
|
||||
ed2.destroy();
|
||||
});
|
||||
|
||||
test('setTheme 触发 themeChange 事件', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
const handler = jest.fn();
|
||||
ed.on('themeChange', handler);
|
||||
ed.setTheme('dark');
|
||||
expect(handler).toHaveBeenCalled();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+25
-2
@@ -1,4 +1,4 @@
|
||||
// Type definitions for MetonaEditor v0.1.4
|
||||
// Type definitions for MetonaEditor v0.1.5
|
||||
// Project: https://git.metona.cn/MetonaTeam/MetonaEditor
|
||||
// Author: thzxx
|
||||
|
||||
@@ -201,6 +201,21 @@ export class MarkdownEditor {
|
||||
getStats(): EditorStats;
|
||||
getStatus(): EditorStatus;
|
||||
|
||||
// 主题(v0.1.5: 实例级主题管理)
|
||||
setTheme(theme: ThemeName): this;
|
||||
getTheme(): string;
|
||||
getThemeContext(): {
|
||||
get(): string;
|
||||
set(theme: string): string;
|
||||
toggle(): string;
|
||||
getResolved(): string;
|
||||
getConfig(): object;
|
||||
getVars(): object;
|
||||
syncWithElement(element: HTMLElement, opts?: object): () => void;
|
||||
adopt(): () => void;
|
||||
watch(source: Function | string): () => void;
|
||||
} | null;
|
||||
|
||||
// 插件
|
||||
use(plugin: string | PluginObject, options?: object): this;
|
||||
getPlugins(): PluginObject[];
|
||||
@@ -307,7 +322,7 @@ export const themeUtils: {
|
||||
getCurrentTheme(): string;
|
||||
getResolvedTheme(): string;
|
||||
applyTheme(theme: ThemeName): void;
|
||||
registerTheme(name: string, config: object): void;
|
||||
registerTheme(name: string, config: { extends?: string; [key: string]: any }): void;
|
||||
unregisterTheme(name: string): void;
|
||||
getAllThemes(): object;
|
||||
getThemeNames(): string[];
|
||||
@@ -317,6 +332,14 @@ export const themeUtils: {
|
||||
addThemeListener(fn: (theme: string, resolved: string) => void): () => void;
|
||||
removeThemeListener(fn: (theme: string, resolved: string) => void): void;
|
||||
clearThemeListeners(): void;
|
||||
// v0.1.5 新增
|
||||
exportCSSVars(el?: HTMLElement): Record<string, string>;
|
||||
getCSSVariable(name: string, el?: HTMLElement): string;
|
||||
applyThemeToElement(theme: string, target: HTMLElement): void;
|
||||
followExternalTheme(opts: object, onChange: (theme: string) => void): () => void;
|
||||
adoptFromParent(container: HTMLElement, onDetected: (theme: string) => void): () => void;
|
||||
watch(source: Function | string, onChange: (theme: string) => void): () => void;
|
||||
createInstanceTheme(editor: object): object;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user