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 库。单文件,开箱即用,中文优先。
|
> 轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用,中文优先。
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/@metona-team/metona-editor)
|
[](https://www.npmjs.com/package/@metona-team/metona-editor)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](./tests)
|
[](./tests)
|
||||||
[](./tests)
|
[](./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
@@ -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
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Constants - 常量定义
|
* MetonaEditor Constants - 常量定义
|
||||||
* @module constants
|
* @module constants
|
||||||
* @version 0.1.4
|
* @version 0.1.5
|
||||||
* @description 默认配置、主题、动画、工具栏等常量
|
* @description 默认配置、主题、动画、工具栏等常量
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
+45
-8
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor i18n - 国际化管理
|
* MetonaEditor i18n - 国际化管理
|
||||||
* @module i18n
|
* @module i18n
|
||||||
* @version 0.1.4
|
* @version 0.1.5
|
||||||
* @description 多语言支持、语言切换和翻译管理
|
* @description 多语言支持、语言切换和翻译管理
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
// 策略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 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
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* MetonaEditor Utils - 工具函数
|
* MetonaEditor Utils - 工具函数
|
||||||
* @module utils
|
* @module utils
|
||||||
* @version 0.1.4
|
* @version 0.1.5
|
||||||
* @description 通用工具函数集合
|
* @description 通用工具函数集合
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
// 即使手动设为 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
|
// 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user