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:
2026-07-24 09:53:20 +08:00
parent f8b9f4a761
commit 517dc34435
16 changed files with 1087 additions and 277 deletions
+3 -3
View File
@@ -2,9 +2,9 @@
> 轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用,中文优先。 > 轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用,中文优先。
[![npm version](https://img.shields.io/badge/version-0.1.4-blue.svg)](https://www.npmjs.com/package/@metona-team/metona-editor) [![npm version](https://img.shields.io/badge/version-0.1.5-blue.svg)](https://www.npmjs.com/package/@metona-team/metona-editor)
[![license](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE) [![license](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
[![tests](https://img.shields.io/badge/tests-469%20passed-brightgreen.svg)](./tests) [![tests](https://img.shields.io/badge/tests-499%20passed-brightgreen.svg)](./tests)
[![coverage](https://img.shields.io/badge/coverage-97%25-brightgreen.svg)](./tests) [![coverage](https://img.shields.io/badge/coverage-97%25-brightgreen.svg)](./tests)
--- ---
@@ -19,7 +19,7 @@
- **安全** — 内置 XSS 防护(`javascript:`/`vbscript:`/`file:` 协议过滤),HTML 转义,`safeUrl` 净化 - **安全** — 内置 XSS 防护(`javascript:`/`vbscript:`/`file:` 协议过滤),HTML 转义,`safeUrl` 净化
- **内置解析器** — 自研 CommonMark 子集 + GFM 扩展(表格含列对齐、任务列表、上下标、数学公式、脚注、嵌套列表、emoji 短码等),可整体替换 - **内置解析器** — 自研 CommonMark 子集 + GFM 扩展(表格含列对齐、任务列表、上下标、数学公式、脚注、嵌套列表、emoji 短码等),可整体替换
- **三模式视图** — edit / split / preview 自由切换,分屏拖拽调整比例,滚动同步,模式切换保持滚动位置 - **三模式视图** — 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` 数字/货币/日期格式化 - **国际化** — zh-CN / en-US 完整翻译,RTL 支持,`Intl` 数字/货币/日期格式化
- **历史栈** — 撤销/重做,防抖合并(可配置延迟),可配置上限 - **历史栈** — 撤销/重做,防抖合并(可配置延迟),可配置上限
- **只读模式** — `readOnly` 配置 / `setReadOnly()` API,适用于预览/展示场景 - **只读模式** — `readOnly` 配置 / `setReadOnly()` API,适用于预览/展示场景
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@metona-team/metona-editor", "name": "@metona-team/metona-editor",
"version": "0.1.4", "version": "0.1.5",
"description": "轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用。", "description": "轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用。",
"type": "module", "type": "module",
"main": "dist/metona-editor.js", "main": "dist/metona-editor.js",
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* MetonaEditor Animations - 动画管理 * MetonaEditor Animations - 动画管理
* @module animations * @module animations
* @version 0.1.4 * @version 0.1.5
* @description 动画注册与管理(当前为 CSS 动画元数据管理,供未来扩展如 Toast/通知组件的进出场动画使用; * @description 动画注册与管理(当前为 CSS 动画元数据管理,供未来扩展如 Toast/通知组件的进出场动画使用;
* 内置 7 种预设动画曲线配置;编辑器核心当前未直接调用此模块) * 内置 7 种预设动画曲线配置;编辑器核心当前未直接调用此模块)
*/ */
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* MetonaEditor Constants - 常量定义 * MetonaEditor Constants - 常量定义
* @module constants * @module constants
* @version 0.1.4 * @version 0.1.5
* @description 默认配置、主题、动画、工具栏等常量 * @description 默认配置、主题、动画、工具栏等常量
*/ */
+45 -8
View File
@@ -1,7 +1,7 @@
/** /**
* MetonaEditor Core - 编辑器核心 * MetonaEditor Core - 编辑器核心
* @module core * @module core
* @version 0.1.4 * @version 0.1.5
* @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换 * @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换
*/ */
@@ -11,7 +11,7 @@ import { injectStyles } from './styles.js';
import { t as i18nT, getCurrentLocale, getLocaleDirection } from './i18n.js'; import { t as i18nT, getCurrentLocale, getLocaleDirection } from './i18n.js';
import { parseMarkdown } from './parser.js'; import { parseMarkdown } from './parser.js';
import { presetPlugins } from './plugins.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']; const MODES = ['edit', 'split', 'preview'];
@@ -20,7 +20,7 @@ const MODES = ['edit', 'split', 'preview'];
*/ */
const resolveThemeName = (theme) => { const resolveThemeName = (theme) => {
if (theme && theme !== 'auto') return theme; if (theme && theme !== 'auto') return theme;
return getTheme('auto'); return resolveTheme('auto');
}; };
/** /**
@@ -99,13 +99,18 @@ class MarkdownEditor {
injectStyles(); injectStyles();
this._buildDOM(); this._buildDOM();
// 主题同步:用户显式传 theme 时覆盖全局 CSS 变量并持久化,否则尊重 localStorage // 实例级主题管理(v0.1.5: 隔离 CSS 变量到 wrapper,支持独立主题)
this._themeCtx = createInstanceTheme(this);
// 向后兼容:用户显式传 theme 时同步到全局并持久化
if (options.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); const resolvedThemeConfig = getThemeConfig(this.config.theme);
setThemeVariables(resolvedThemeConfig, this.el); setThemeVariables(resolvedThemeConfig, this.el);
@@ -981,6 +986,38 @@ class MarkdownEditor {
isDestroyed() { return this._destroyed; } 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 { return {
id: this.id, id: this.id,
mode: this._mode, mode: this._mode,
theme: this.config.theme, theme: this.getTheme(),
locale: getCurrentLocale(), locale: getCurrentLocale(),
fullscreen: this._fullscreen, fullscreen: this._fullscreen,
readOnly: this.config.readOnly || false, readOnly: this.config.readOnly || false,
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* MetonaEditor i18n - 国际化管理 * MetonaEditor i18n - 国际化管理
* @module i18n * @module i18n
* @version 0.1.4 * @version 0.1.5
* @description 多语言支持、语言切换和翻译管理 * @description 多语言支持、语言切换和翻译管理
*/ */
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* MetonaEditor Icons — 工具栏图标SVG定义 * MetonaEditor Icons — 工具栏图标SVG定义
* @module icons * @module icons
* @version 0.1.4 * @version 0.1.5
* @description 工具栏格式化按钮 SVG 图标 * @description 工具栏格式化按钮 SVG 图标
*/ */
+15 -3
View File
@@ -1,7 +1,7 @@
/** /**
* MetonaEditor - 轻量级 Markdown Editor 库 * MetonaEditor - 轻量级 Markdown Editor 库
* @module metona-editor * @module metona-editor
* @version 0.1.4 * @version 0.1.5
* @author thzxx * @author thzxx
* @description 现代化、零依赖、易扩展、易维护、易使用的 Markdown 编辑器库。单文件,开箱即用。 * @description 现代化、零依赖、易扩展、易维护、易使用的 Markdown 编辑器库。单文件,开箱即用。
* @license MIT * @license MIT
@@ -22,14 +22,14 @@
import { MarkdownEditor } from './core.js'; import { MarkdownEditor } from './core.js';
import { parseMarkdown, safeUrl, slugify, clearRenderCache } from './parser.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 { i18nUtils } from './i18n.js';
import { pluginUtils, presetPlugins } from './plugins.js'; import { pluginUtils, presetPlugins } from './plugins.js';
import { animationUtils } from './animations.js'; import { animationUtils } from './animations.js';
import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants.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, plugins: pluginUtils,
presetPlugins, presetPlugins,
// 主题工具(v0.1.5 新增)
exportCSSVars,
getCSSVariable,
followExternalTheme,
adoptFromParent,
createInstanceTheme,
// 常量 // 常量
DEFAULTS, DEFAULTS,
ICONS, ICONS,
@@ -240,6 +247,11 @@ export {
pluginUtils, pluginUtils,
presetPlugins, presetPlugins,
animationUtils, animationUtils,
exportCSSVars,
getCSSVariable,
followExternalTheme,
adoptFromParent,
createInstanceTheme,
DEFAULTS, DEFAULTS,
ICONS, ICONS,
THEMES, THEMES,
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* MetonaEditor Locales — 国际化翻译数据 * MetonaEditor Locales — 国际化翻译数据
* @module locales * @module locales
* @version 0.1.4 * @version 0.1.5
* @description 内置 zh-CN / en-US 完整翻译 * @description 内置 zh-CN / en-US 完整翻译
*/ */
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* MetonaEditor Parser - 轻量 Markdown 解析器(增强版) * MetonaEditor Parser - 轻量 Markdown 解析器(增强版)
* @module parser * @module parser
* @version 0.1.4 * @version 0.1.5
* @description 自研零依赖 Markdown 解析器,覆盖 CommonMark 子集 + GFM 扩展 * @description 自研零依赖 Markdown 解析器,覆盖 CommonMark 子集 + GFM 扩展
* *
* v0.1.4 增强: * v0.1.4 增强:
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* MetonaEditor Plugins - 插件系统 * MetonaEditor Plugins - 插件系统
* @module plugins * @module plugins
* @version 0.1.4 * @version 0.1.5
* @description 插件管理器 + 预设插件(autoSave / exportTool / searchReplace * @description 插件管理器 + 预设插件(autoSave / exportTool / searchReplace
* *
* 插件约定: * 插件约定:
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* MetonaEditor Styles - 编辑器样式 * MetonaEditor Styles - 编辑器样式
* @module styles * @module styles
* @version 0.1.4 * @version 0.1.5
* @description 编辑器 UI 样式注入与管理 * @description 编辑器 UI 样式注入与管理
*/ */
+580 -231
View File
@@ -1,305 +1,609 @@
/** /**
* MetonaEditor Themes - 主题管理 * MetonaEditor Themes 主题系统(增强版)
* @module themes * @module themes
* @version 0.1.4 * @version 0.1.5
* @description 主题系统、自定义主题和主题切换 * @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 { THEMES } from './constants.js';
import { prefersDark } from './utils.js'; import { prefersDark } from './utils.js';
let currentTheme = 'auto'; // ============ 模块级状态(全局默认,向后兼容) ============
let themeListeners = new Set();
let globalCurrentTheme = 'auto';
let globalThemeListeners = new Set();
let systemThemeMediaQuery = null; let systemThemeMediaQuery = null;
let systemThemeListener = null; let systemThemeListener = null;
// ============ 系统主题 ============
/** /**
* 获取系统主题 * 获取系统主题偏好
* @returns {'light'|'dark'}
*/ */
export const getSystemTheme = () => { const getSystemTheme = () => {
if (typeof window === 'undefined') return 'light'; if (typeof window === 'undefined') return 'light';
return prefersDark() ? 'dark' : 'light'; return prefersDark() ? 'dark' : 'light';
}; };
/** /**
* 获取主题(别名 * 监听系统主题变化(内部回调版
* @param {(theme: 'light'|'dark') => void} callback
* @returns {() => void} 取消监听函数
*/ */
export const getTheme = (theme) => { const _watchSystem = (callback) => {
return resolveTheme(theme); 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) => { const watchSystemTheme = () => {
if (theme === 'auto') { if (typeof window === 'undefined' || !window.matchMedia) return;
if (currentTheme && currentTheme !== 'auto') {
return currentTheme; // 清理旧监听
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 getSystemTheme();
} }
return theme; return theme;
}; };
/** /**
* 获取主题配置 * 获取主题配置对象
* @param {string} theme - 主题名
* @returns {Object} 主题配置
*/ */
export const getThemeConfig = (theme) => { const getThemeConfig = (theme) => {
const resolved = resolveTheme(theme); const resolved = resolveTheme(theme);
return THEMES[resolved] || THEMES.light; return THEMES[resolved] || THEMES.light;
}; };
/** // ============ CSS 变量操作 ============
* 应用主题
*/
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 变量
* @param {Object} config - 主题配置对象 * @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; if (typeof document === 'undefined' || !config || typeof config !== 'object') return;
const root = target || document.documentElement; const root = target || document.documentElement;
// 基础变量(保留用于全局与组件回退) const vars = {
root.style.setProperty('--md-bg', config.bg); '--md-bg': config.bg,
root.style.setProperty('--md-text', config.text); '--md-text': config.text,
root.style.setProperty('--md-border', config.border); '--md-border': config.border,
root.style.setProperty('--md-shadow', config.shadow); '--md-shadow': config.shadow,
root.style.setProperty('--md-hover-shadow', config.hoverShadow); '--md-hover-shadow': config.hoverShadow,
// 编辑器专属变量 '--md-toolbar-bg': config.toolbarBg || config.bg,
root.style.setProperty('--md-toolbar-bg', config.toolbarBg || config.bg); '--md-textarea-bg': config.textareaBg || config.bg,
root.style.setProperty('--md-textarea-bg', config.textareaBg || config.bg); '--md-preview-bg': config.previewBg || config.bg,
root.style.setProperty('--md-preview-bg', config.previewBg || config.bg); '--md-code-bg': config.codeBg || 'rgba(127,127,127,0.1)',
root.style.setProperty('--md-code-bg', config.codeBg || 'rgba(127,127,127,0.1)'); '--md-code-text': config.codeText || config.text,
root.style.setProperty('--md-code-text', config.codeText || config.text); '--md-accent': config.accent || '#3b82f6',
root.style.setProperty('--md-accent', config.accent || '#3b82f6'); '--md-muted': config.muted || '#9ca3af',
root.style.setProperty('--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.progressBg) root.style.setProperty('--md-progress-bg', config.progressBg);
if (config.closeHoverBg) root.style.setProperty('--md-close-hover-bg', config.closeHoverBg); if (config.closeHoverBg) root.style.setProperty('--md-close-hover-bg', config.closeHoverBg);
}; };
/** /**
* 获取当前主题 * 导出当前 CSS 变量值(从指定元素读取)
* @param {HTMLElement} [el] - 读取的元素(默认 documentElement
* @returns {Object} CSS 变量键值对
*/ */
export const getCurrentTheme = () => { const exportCSSVars = (el) => {
return currentTheme; 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 = () => { const getCSSVariable = (name, el) => {
return resolveTheme(currentTheme); 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); applyTheme(theme);
saveTheme(theme); saveTheme(theme);
}; };
/** /**
* 切换亮色/暗色主题 * 在 light/dark 间切换
*/ */
export const toggleTheme = () => { const toggleTheme = () => {
const resolved = getResolvedTheme(); const resolved = getResolvedTheme();
const newTheme = resolved === 'dark' ? 'light' : 'dark'; switchTheme(resolved === 'dark' ? 'light' : 'dark');
switchTheme(newTheme);
}; };
/** /**
* 重置为自动主题 * 重置为自动主题
*/ */
export const resetToAuto = () => { const resetToAuto = () => {
switchTheme('auto'); switchTheme('auto');
}; };
/** /**
* 保存主题到本地存储 * 获取全局当前主题名
*/ */
export const saveTheme = (theme) => { const getCurrentTheme = () => globalCurrentTheme;
/**
* 获取解析后的全局主题
*/
const getResolvedTheme = () => resolveTheme(globalCurrentTheme);
// ============ 主题持久化 ============
const saveTheme = (theme) => {
if (typeof localStorage !== 'undefined') { if (typeof localStorage !== 'undefined') {
try { try { localStorage.setItem('metona-editor-theme', theme); } catch (_) {}
localStorage.setItem('metona-editor-theme', theme);
} catch (e) {
console.warn('Failed to save theme:', e);
}
} }
}; };
/** const loadTheme = () => {
* 从本地存储加载主题
*/
export const loadTheme = () => {
if (typeof localStorage !== 'undefined') { if (typeof localStorage !== 'undefined') {
try { try { return localStorage.getItem('metona-editor-theme') || 'auto'; } catch (_) {}
return localStorage.getItem('metona-editor-theme') || 'auto';
} catch (e) {
console.warn('Failed to load theme:', e);
}
} }
return 'auto'; return 'auto';
}; };
/** // ============ 全局监听 ============
* 初始化主题系统
*/
export const initTheme = () => {
const savedTheme = loadTheme();
applyTheme(savedTheme);
watchSystemTheme();
};
/** const notifyGlobalListeners = (theme, resolved) => {
* 监听系统主题变化 globalThemeListeners.forEach((fn) => {
*/ try { fn(theme, resolved); } catch (e) { console.error('Theme listener error:', e); }
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 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 = () => { const followExternalTheme = (options, onThemeChange) => {
themeListeners.clear(); 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} 取消跟随函数
*/ */
export const registerTheme = (name, config) => { const adoptFromParent = (container, onThemeDetected) => {
const base = THEMES.light; 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] = { THEMES[name] = {
bg: config.bg || base.bg, bg: config.bg || base.bg,
text: config.text || base.text, text: config.text || base.text,
border: config.border || base.border, border: config.border || base.border,
shadow: config.shadow || base.shadow, shadow: config.shadow || base.shadow,
hoverShadow: config.hoverShadow || base.hoverShadow, hoverShadow: config.hoverShadow || base.hoverShadow,
toolbarBg: config.toolbarBg || config.bg || base.toolbarBg, toolbarBg: config.toolbarBg || base.toolbarBg || base.bg,
textareaBg: config.textareaBg || config.bg || base.textareaBg, textareaBg: config.textareaBg || base.textareaBg || base.bg,
previewBg: config.previewBg || config.bg || base.previewBg, previewBg: config.previewBg || base.previewBg || base.bg,
codeBg: config.codeBg || base.codeBg, codeBg: config.codeBg || base.codeBg,
codeText: config.codeText || base.codeText, codeText: config.codeText || base.codeText,
accent: config.accent || base.accent, accent: config.accent || base.accent,
muted: config.muted || base.muted, muted: config.muted || base.muted,
// 兼容旧字段 progressBg: config.progressBg || base.progressBg,
progressBg: config.progressBg, closeHoverBg: config.closeHoverBg || base.closeHoverBg,
closeHoverBg: config.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) => { const createThemeManager = () => ({
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 = {
getSystemTheme, getSystemTheme,
resolveTheme, resolveTheme,
getThemeConfig, getThemeConfig,
@@ -322,46 +626,80 @@ export const themeUtils = {
hasTheme, hasTheme,
saveTheme, saveTheme,
loadTheme, 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 中有预设
* 预设主题
*/
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,
},
};
// 注册预设主题
Object.entries(presetThemes).forEach(([name, theme]) => { Object.entries(presetThemes).forEach(([name, theme]) => {
if (name !== 'auto' && theme.config !== 'auto') { if (name !== 'auto' && theme.config !== 'auto') {
THEMES[name] = theme.config; THEMES[name] = theme.config;
} }
}); });
/** // ============ 工具代理层 ============
* 创建主题管理器
*/ const themeUtils = {
export const createThemeManager = () => { // 系统
return { 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, getSystemTheme,
resolveTheme, resolveTheme,
getThemeConfig, getThemeConfig,
@@ -371,6 +709,8 @@ export const createThemeManager = () => {
switchTheme, switchTheme,
toggleTheme, toggleTheme,
resetToAuto, resetToAuto,
saveTheme,
loadTheme,
initTheme, initTheme,
watchSystemTheme, watchSystemTheme,
unwatchSystemTheme, unwatchSystemTheme,
@@ -382,9 +722,18 @@ export const createThemeManager = () => {
getAllThemes, getAllThemes,
getThemeNames, getThemeNames,
hasTheme, hasTheme,
saveTheme, setThemeVariables,
loadTheme, exportCSSVars,
}; getCSSVariable,
applyThemeToElement,
followExternalTheme,
adoptFromParent,
watch,
createInstanceTheme,
createThemeManager,
presetThemes,
}; };
export { themeUtils as default }; // 向后兼容别名
const getTheme = resolveTheme;
export { getTheme };
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* MetonaEditor Utils - 工具函数 * MetonaEditor Utils - 工具函数
* @module utils * @module utils
* @version 0.1.4 * @version 0.1.5
* @description 通用工具函数集合 * @description 通用工具函数集合
*/ */
+389
View File
@@ -34,8 +34,16 @@ import {
setThemeVariables, setThemeVariables,
presetThemes, presetThemes,
createThemeManager, createThemeManager,
exportCSSVars,
getCSSVariable,
applyThemeToElement,
followExternalTheme,
adoptFromParent,
watch,
createInstanceTheme,
} from '../src/themes.js'; } from '../src/themes.js';
import { THEMES } from '../src/constants.js'; import { THEMES } from '../src/constants.js';
import { MarkdownEditor } from '../src/core.js';
// 确保 localStorage 存在(jsdom 兼容) // 确保 localStorage 存在(jsdom 兼容)
if (typeof global.localStorage === 'undefined') { if (typeof global.localStorage === 'undefined') {
@@ -431,3 +439,384 @@ describe('createThemeManager', () => {
expect(m.getCurrentTheme()).toBe('dark'); 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');
// 即使手动设为 darkresolveTheme('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');
// 手动触发 MutationObserverjsdom 中需手动 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();
});
});
+25 -2
View File
@@ -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 // Project: https://git.metona.cn/MetonaTeam/MetonaEditor
// Author: thzxx // Author: thzxx
@@ -201,6 +201,21 @@ export class MarkdownEditor {
getStats(): EditorStats; getStats(): EditorStats;
getStatus(): EditorStatus; 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; use(plugin: string | PluginObject, options?: object): this;
getPlugins(): PluginObject[]; getPlugins(): PluginObject[];
@@ -307,7 +322,7 @@ export const themeUtils: {
getCurrentTheme(): string; getCurrentTheme(): string;
getResolvedTheme(): string; getResolvedTheme(): string;
applyTheme(theme: ThemeName): void; applyTheme(theme: ThemeName): void;
registerTheme(name: string, config: object): void; registerTheme(name: string, config: { extends?: string; [key: string]: any }): void;
unregisterTheme(name: string): void; unregisterTheme(name: string): void;
getAllThemes(): object; getAllThemes(): object;
getThemeNames(): string[]; getThemeNames(): string[];
@@ -317,6 +332,14 @@ export const themeUtils: {
addThemeListener(fn: (theme: string, resolved: string) => void): () => void; addThemeListener(fn: (theme: string, resolved: string) => void): () => void;
removeThemeListener(fn: (theme: string, resolved: string) => void): void; removeThemeListener(fn: (theme: string, resolved: string) => void): void;
clearThemeListeners(): 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; [key: string]: any;
}; };