diff --git a/README.md b/README.md index 0e9f23e..7d60a0b 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ > 轻量、零依赖、精致美观的 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) -[![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) --- @@ -19,7 +19,7 @@ - **安全** — 内置 XSS 防护(`javascript:`/`vbscript:`/`file:` 协议过滤),HTML 转义,`safeUrl` 净化 - **内置解析器** — 自研 CommonMark 子集 + GFM 扩展(表格含列对齐、任务列表、上下标、数学公式、脚注、嵌套列表、emoji 短码等),可整体替换 - **三模式视图** — edit / split / preview 自由切换,分屏拖拽调整比例,滚动同步,模式切换保持滚动位置 -- **主题系统** — light / dark / auto / warm 四套预设,CSS 变量定制,跟随系统主题,@media print 打印样式 +- **主题系统** — light / dark / auto / warm 四套预设,CSS 变量定制,实例级主题隔离,外部主题跟随(syncWithElement / adoptFromParent),主题继承(extends),跟随系统主题,@media print 打印样式 - **国际化** — zh-CN / en-US 完整翻译,RTL 支持,`Intl` 数字/货币/日期格式化 - **历史栈** — 撤销/重做,防抖合并(可配置延迟),可配置上限 - **只读模式** — `readOnly` 配置 / `setReadOnly()` API,适用于预览/展示场景 diff --git a/package.json b/package.json index 045cb95..4d99d7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metona-team/metona-editor", - "version": "0.1.4", + "version": "0.1.5", "description": "轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用。", "type": "module", "main": "dist/metona-editor.js", diff --git a/src/animations.js b/src/animations.js index 74740a7..87e6689 100644 --- a/src/animations.js +++ b/src/animations.js @@ -1,7 +1,7 @@ /** * MetonaEditor Animations - 动画管理 * @module animations - * @version 0.1.4 + * @version 0.1.5 * @description 动画注册与管理(当前为 CSS 动画元数据管理,供未来扩展如 Toast/通知组件的进出场动画使用; * 内置 7 种预设动画曲线配置;编辑器核心当前未直接调用此模块) */ diff --git a/src/constants.js b/src/constants.js index d75471a..3645c3b 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,7 +1,7 @@ /** * MetonaEditor Constants - 常量定义 * @module constants - * @version 0.1.4 + * @version 0.1.5 * @description 默认配置、主题、动画、工具栏等常量 */ diff --git a/src/core.js b/src/core.js index 3fb0c51..306d9e1 100644 --- a/src/core.js +++ b/src/core.js @@ -1,7 +1,7 @@ /** * MetonaEditor Core - 编辑器核心 * @module core - * @version 0.1.4 + * @version 0.1.5 * @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换 */ @@ -11,7 +11,7 @@ import { injectStyles } from './styles.js'; import { t as i18nT, getCurrentLocale, getLocaleDirection } from './i18n.js'; import { parseMarkdown } from './parser.js'; import { presetPlugins } from './plugins.js'; -import { getTheme, applyTheme, saveTheme, getThemeConfig, setThemeVariables } from './themes.js'; +import { resolveTheme, getThemeConfig, setThemeVariables, createInstanceTheme } from './themes.js'; const MODES = ['edit', 'split', 'preview']; @@ -20,7 +20,7 @@ const MODES = ['edit', 'split', 'preview']; */ const resolveThemeName = (theme) => { if (theme && theme !== 'auto') return theme; - return getTheme('auto'); + return resolveTheme('auto'); }; /** @@ -99,13 +99,18 @@ class MarkdownEditor { injectStyles(); this._buildDOM(); - // 主题同步:用户显式传 theme 时覆盖全局 CSS 变量并持久化,否则尊重 localStorage + // 实例级主题管理(v0.1.5: 隔离 CSS 变量到 wrapper,支持独立主题) + this._themeCtx = createInstanceTheme(this); + + // 向后兼容:用户显式传 theme 时同步到全局并持久化 if (options.theme) { - applyTheme(this.config.theme); - saveTheme(this.config.theme); + // 全局同步(保留原有行为) + if (typeof document !== 'undefined') { + document.documentElement.setAttribute('data-md-theme', resolveTheme(options.theme)); + } } - // 将 CSS 变量限定到当前实例的 wrapper 元素,避免污染全局样式 + // 确保 CSS 变量作用到实例 wrapper const resolvedThemeConfig = getThemeConfig(this.config.theme); setThemeVariables(resolvedThemeConfig, this.el); @@ -981,6 +986,38 @@ class MarkdownEditor { isDestroyed() { return this._destroyed; } + /** + * 设置实例主题(v0.1.5: 实例隔离,不影响其他编辑器) + * @param {string} theme - 主题名 + * @returns {MarkdownEditor} + */ + setTheme(theme) { + if (!theme) return this; + this.config.theme = theme; + if (this._themeCtx) { + this._themeCtx.set(theme); + } + return this; + } + + /** + * 获取实例当前主题名 + * @returns {string} + */ + getTheme() { + if (this._themeCtx) return this._themeCtx.get(); + return this.config.theme || 'auto'; + } + + /** + * 获取实例主题上下文(高级 API) + * 提供 syncWithElement / adopt / watch 等外部跟随能力 + * @returns {Object|null} + */ + getThemeContext() { + return this._themeCtx || null; + } + /** * 获取编辑器状态 */ @@ -988,7 +1025,7 @@ class MarkdownEditor { return { id: this.id, mode: this._mode, - theme: this.config.theme, + theme: this.getTheme(), locale: getCurrentLocale(), fullscreen: this._fullscreen, readOnly: this.config.readOnly || false, diff --git a/src/i18n.js b/src/i18n.js index 241f22f..cf5ed8d 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1,7 +1,7 @@ /** * MetonaEditor i18n - 国际化管理 * @module i18n - * @version 0.1.4 + * @version 0.1.5 * @description 多语言支持、语言切换和翻译管理 */ diff --git a/src/icons.js b/src/icons.js index 9ee4ed9..74990e7 100644 --- a/src/icons.js +++ b/src/icons.js @@ -1,7 +1,7 @@ /** * MetonaEditor Icons — 工具栏图标SVG定义 * @module icons - * @version 0.1.4 + * @version 0.1.5 * @description 工具栏格式化按钮 SVG 图标 */ diff --git a/src/index.js b/src/index.js index 856e98c..7746d94 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,7 @@ /** * MetonaEditor - 轻量级 Markdown Editor 库 * @module metona-editor - * @version 0.1.4 + * @version 0.1.5 * @author thzxx * @description 现代化、零依赖、易扩展、易维护、易使用的 Markdown 编辑器库。单文件,开箱即用。 * @license MIT @@ -22,14 +22,14 @@ import { MarkdownEditor } from './core.js'; import { parseMarkdown, safeUrl, slugify, clearRenderCache } from './parser.js'; -import { themeUtils } from './themes.js'; +import { themeUtils, exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme } from './themes.js'; import { i18nUtils } from './i18n.js'; import { pluginUtils, presetPlugins } from './plugins.js'; import { animationUtils } from './animations.js'; import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants.js'; // 版本信息 -const VERSION = '0.1.4'; +const VERSION = '0.1.5'; /** * 全局默认插件队列 @@ -202,6 +202,13 @@ const api = { plugins: pluginUtils, presetPlugins, + // 主题工具(v0.1.5 新增) + exportCSSVars, + getCSSVariable, + followExternalTheme, + adoptFromParent, + createInstanceTheme, + // 常量 DEFAULTS, ICONS, @@ -240,6 +247,11 @@ export { pluginUtils, presetPlugins, animationUtils, + exportCSSVars, + getCSSVariable, + followExternalTheme, + adoptFromParent, + createInstanceTheme, DEFAULTS, ICONS, THEMES, diff --git a/src/locales.js b/src/locales.js index c41ec1f..2ae1475 100644 --- a/src/locales.js +++ b/src/locales.js @@ -1,7 +1,7 @@ /** * MetonaEditor Locales — 国际化翻译数据 * @module locales - * @version 0.1.4 + * @version 0.1.5 * @description 内置 zh-CN / en-US 完整翻译 */ diff --git a/src/parser.js b/src/parser.js index 8bb6664..4e158c1 100644 --- a/src/parser.js +++ b/src/parser.js @@ -1,7 +1,7 @@ /** * MetonaEditor Parser - 轻量 Markdown 解析器(增强版) * @module parser - * @version 0.1.4 + * @version 0.1.5 * @description 自研零依赖 Markdown 解析器,覆盖 CommonMark 子集 + GFM 扩展 * * v0.1.4 增强: diff --git a/src/plugins.js b/src/plugins.js index eacf4e2..04f1c12 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -1,7 +1,7 @@ /** * MetonaEditor Plugins - 插件系统 * @module plugins - * @version 0.1.4 + * @version 0.1.5 * @description 插件管理器 + 预设插件(autoSave / exportTool / searchReplace) * * 插件约定: diff --git a/src/styles.js b/src/styles.js index a28e784..f1083de 100644 --- a/src/styles.js +++ b/src/styles.js @@ -1,7 +1,7 @@ /** * MetonaEditor Styles - 编辑器样式 * @module styles - * @version 0.1.4 + * @version 0.1.5 * @description 编辑器 UI 样式注入与管理 */ diff --git a/src/themes.js b/src/themes.js index f654d1c..eb3884d 100644 --- a/src/themes.js +++ b/src/themes.js @@ -1,305 +1,609 @@ /** - * MetonaEditor Themes - 主题管理 + * MetonaEditor Themes — 主题系统(增强版) * @module themes - * @version 0.1.4 - * @description 主题系统、自定义主题和主题切换 + * @version 0.1.5 + * @description 全局+实例级主题管理、CSS 变量隔离、外部主题跟随、主题继承 + * + * v0.1.5 增强: + * - 修复 resolveTheme('auto') 始终解析为系统主题 + * - 实例级 setTheme() / getTheme(),多实例独立主题 + * - 实例级 themeChange 事件通知 + * - 外部主题跟随:syncWithElement() / adoptFromParent() / watch() + * - 主题继承:registerTheme(name, { extends: 'light', ... }) + * - 导出 CSS 变量:exportCSSVars() / getCSSVariable() + * - 主题悬停/聚焦状态变量支持 + * - 应用主题到指定元素(不污染 :root) */ import { THEMES } from './constants.js'; import { prefersDark } from './utils.js'; -let currentTheme = 'auto'; -let themeListeners = new Set(); +// ============ 模块级状态(全局默认,向后兼容) ============ + +let globalCurrentTheme = 'auto'; +let globalThemeListeners = new Set(); let systemThemeMediaQuery = null; let systemThemeListener = null; +// ============ 系统主题 ============ + /** - * 获取系统主题 + * 获取系统主题偏好 + * @returns {'light'|'dark'} */ -export const getSystemTheme = () => { +const getSystemTheme = () => { if (typeof window === 'undefined') return 'light'; return prefersDark() ? 'dark' : 'light'; }; /** - * 获取主题(别名) + * 监听系统主题变化(内部回调版) + * @param {(theme: 'light'|'dark') => void} callback + * @returns {() => void} 取消监听函数 */ -export const getTheme = (theme) => { - return resolveTheme(theme); +const _watchSystem = (callback) => { + if (typeof window === 'undefined' || !window.matchMedia) return () => {}; + const mql = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = (e) => callback(e.matches ? 'dark' : 'light'); + mql.addEventListener('change', handler); + return () => mql.removeEventListener('change', handler); }; /** - * 解析主题 + * 监听系统主题变化(全局单例模式,向后兼容) + * 自动在 auto 模式下跟随系统主题切换 */ -export const resolveTheme = (theme) => { - if (theme === 'auto') { - if (currentTheme && currentTheme !== 'auto') { - return currentTheme; +const watchSystemTheme = () => { + if (typeof window === 'undefined' || !window.matchMedia) return; + + // 清理旧监听 + if (_sysWatchUnsub) { _sysWatchUnsub(); _sysWatchUnsub = null; } + + _sysWatchUnsub = _watchSystem((sysTheme) => { + if (globalCurrentTheme === 'auto') { + applyTheme('auto'); } + }); +}; + +// ============ 主题解析 ============ + +/** + * 解析主题名为实际主题值 + * 修复:auto 始终解析为系统当前主题,不再被上次手动设置污染 + * @param {string} theme - 主题名 + * @returns {string} 解析后的主题名 + */ +const resolveTheme = (theme) => { + if (!theme || theme === 'auto') { return getSystemTheme(); } return theme; }; /** - * 获取主题配置 + * 获取主题配置对象 + * @param {string} theme - 主题名 + * @returns {Object} 主题配置 */ -export const getThemeConfig = (theme) => { +const getThemeConfig = (theme) => { const resolved = resolveTheme(theme); return THEMES[resolved] || THEMES.light; }; -/** - * 应用主题 - */ -export const applyTheme = (theme) => { - currentTheme = theme; - const resolved = resolveTheme(theme); - - if (typeof document !== 'undefined') { - document.documentElement.setAttribute('data-md-theme', resolved); - document.documentElement.classList.remove('md-theme-light', 'md-theme-dark', 'md-theme-auto'); - document.documentElement.classList.add(`md-theme-${resolved}`); - - const config = getThemeConfig(theme); - setThemeVariables(config); - } - - notifyThemeListeners(theme, resolved); -}; +// ============ CSS 变量操作 ============ /** - * 设置主题CSS变量 + * 将主题配置写入 CSS 变量 * @param {Object} config - 主题配置对象 - * @param {HTMLElement} [target] - 目标元素,不传则设置到 documentElement(全局默认) + * @param {HTMLElement} [target] - 目标元素(默认 documentElement) */ -export const setThemeVariables = (config, target) => { +const setThemeVariables = (config, target) => { if (typeof document === 'undefined' || !config || typeof config !== 'object') return; const root = target || document.documentElement; - // 基础变量(保留用于全局与组件回退) - root.style.setProperty('--md-bg', config.bg); - root.style.setProperty('--md-text', config.text); - root.style.setProperty('--md-border', config.border); - root.style.setProperty('--md-shadow', config.shadow); - root.style.setProperty('--md-hover-shadow', config.hoverShadow); - // 编辑器专属变量 - root.style.setProperty('--md-toolbar-bg', config.toolbarBg || config.bg); - root.style.setProperty('--md-textarea-bg', config.textareaBg || config.bg); - root.style.setProperty('--md-preview-bg', config.previewBg || config.bg); - root.style.setProperty('--md-code-bg', config.codeBg || 'rgba(127,127,127,0.1)'); - root.style.setProperty('--md-code-text', config.codeText || config.text); - root.style.setProperty('--md-accent', config.accent || '#3b82f6'); - root.style.setProperty('--md-muted', config.muted || '#9ca3af'); + const vars = { + '--md-bg': config.bg, + '--md-text': config.text, + '--md-border': config.border, + '--md-shadow': config.shadow, + '--md-hover-shadow': config.hoverShadow, + '--md-toolbar-bg': config.toolbarBg || config.bg, + '--md-textarea-bg': config.textareaBg || config.bg, + '--md-preview-bg': config.previewBg || config.bg, + '--md-code-bg': config.codeBg || 'rgba(127,127,127,0.1)', + '--md-code-text': config.codeText || config.text, + '--md-accent': config.accent || '#3b82f6', + '--md-muted': config.muted || '#9ca3af', + }; + + Object.entries(vars).forEach(([k, v]) => { + if (v !== undefined && v !== null) { + root.style.setProperty(k, String(v)); + } + }); + // 兼容旧字段 if (config.progressBg) root.style.setProperty('--md-progress-bg', config.progressBg); if (config.closeHoverBg) root.style.setProperty('--md-close-hover-bg', config.closeHoverBg); }; /** - * 获取当前主题 + * 导出当前 CSS 变量值(从指定元素读取) + * @param {HTMLElement} [el] - 读取的元素(默认 documentElement) + * @returns {Object} CSS 变量键值对 */ -export const getCurrentTheme = () => { - return currentTheme; +const exportCSSVars = (el) => { + const root = el || document.documentElement; + if (typeof document === 'undefined') return {}; + const style = getComputedStyle(root); + const varNames = [ + '--md-bg', '--md-text', '--md-border', '--md-shadow', + '--md-hover-shadow', '--md-toolbar-bg', '--md-textarea-bg', + '--md-preview-bg', '--md-code-bg', '--md-code-text', + '--md-accent', '--md-muted', + ]; + const result = {}; + varNames.forEach((name) => { + const val = style.getPropertyValue(name).trim(); + if (val) result[name] = val; + }); + return result; }; /** - * 获取解析后的主题 + * 获取单个 CSS 变量值 + * @param {string} name - 变量名(自动补全 --md- 前缀) + * @param {HTMLElement} [el] - 读取的元素 + * @returns {string} 变量值 */ -export const getResolvedTheme = () => { - return resolveTheme(currentTheme); +const getCSSVariable = (name, el) => { + const root = el || document.documentElement; + if (typeof document === 'undefined') return ''; + const fullName = name.startsWith('--') ? name : `--md-${name}`; + return getComputedStyle(root).getPropertyValue(fullName).trim(); +}; + +// ============ 主题切换与应用 ============ + +/** + * 应用主题到全局(向后兼容) + * @param {string} theme - 主题名 + */ +const applyTheme = (theme) => { + globalCurrentTheme = theme; + const resolved = resolveTheme(theme); + + if (typeof document !== 'undefined') { + document.documentElement.setAttribute('data-md-theme', resolved); + document.documentElement.classList.remove('md-theme-light', 'md-theme-dark', 'md-theme-auto', 'md-theme-warm'); + document.documentElement.classList.add(`md-theme-${resolved}`); + + const config = getThemeConfig(theme); + setThemeVariables(config); + } + + notifyGlobalListeners(theme, resolved); }; /** - * 切换主题 + * 应用主题到指定元素(不改变全局状态) + * @param {string} theme - 主题名 + * @param {HTMLElement} target - 目标 DOM 元素 */ -export const switchTheme = (theme) => { +const applyThemeToElement = (theme, target) => { + if (!target || typeof document === 'undefined') return; + const resolved = resolveTheme(theme); + const config = getThemeConfig(theme); + + target.setAttribute('data-md-theme', resolved); + target.classList.remove('md-theme-light', 'md-theme-dark', 'md-theme-auto', 'md-theme-warm'); + if (resolved !== 'auto') { + target.classList.add(`md-theme-${resolved}`); + } + + setThemeVariables(config, target); +}; + +/** + * 切换主题并持久化 + */ +const switchTheme = (theme) => { applyTheme(theme); saveTheme(theme); }; /** - * 切换亮色/暗色主题 + * 在 light/dark 间切换 */ -export const toggleTheme = () => { +const toggleTheme = () => { const resolved = getResolvedTheme(); - const newTheme = resolved === 'dark' ? 'light' : 'dark'; - switchTheme(newTheme); + switchTheme(resolved === 'dark' ? 'light' : 'dark'); }; /** * 重置为自动主题 */ -export const resetToAuto = () => { +const resetToAuto = () => { switchTheme('auto'); }; /** - * 保存主题到本地存储 + * 获取全局当前主题名 */ -export const saveTheme = (theme) => { +const getCurrentTheme = () => globalCurrentTheme; + +/** + * 获取解析后的全局主题 + */ +const getResolvedTheme = () => resolveTheme(globalCurrentTheme); + +// ============ 主题持久化 ============ + +const saveTheme = (theme) => { if (typeof localStorage !== 'undefined') { - try { - localStorage.setItem('metona-editor-theme', theme); - } catch (e) { - console.warn('Failed to save theme:', e); - } + try { localStorage.setItem('metona-editor-theme', theme); } catch (_) {} } }; -/** - * 从本地存储加载主题 - */ -export const loadTheme = () => { +const loadTheme = () => { if (typeof localStorage !== 'undefined') { - try { - return localStorage.getItem('metona-editor-theme') || 'auto'; - } catch (e) { - console.warn('Failed to load theme:', e); - } + try { return localStorage.getItem('metona-editor-theme') || 'auto'; } catch (_) {} } return 'auto'; }; -/** - * 初始化主题系统 - */ -export const initTheme = () => { - const savedTheme = loadTheme(); - applyTheme(savedTheme); - watchSystemTheme(); -}; +// ============ 全局监听 ============ -/** - * 监听系统主题变化 - */ -export const watchSystemTheme = () => { - if (typeof window === 'undefined' || !window.matchMedia) return; - - if (systemThemeMediaQuery && systemThemeListener) { - systemThemeMediaQuery.removeEventListener('change', systemThemeListener); - } - - systemThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - systemThemeListener = (e) => { - if (currentTheme === 'auto') { - applyTheme('auto'); - } - }; - - systemThemeMediaQuery.addEventListener('change', systemThemeListener); -}; - -/** - * 停止监听系统主题变化 - */ -export const unwatchSystemTheme = () => { - if (systemThemeMediaQuery && systemThemeListener) { - systemThemeMediaQuery.removeEventListener('change', systemThemeListener); - systemThemeMediaQuery = null; - systemThemeListener = null; - } -}; - -/** - * 添加主题监听器 - */ -export const addThemeListener = (listener) => { - themeListeners.add(listener); - - return () => { - themeListeners.delete(listener); - }; -}; - -/** - * 移除主题监听器 - */ -export const removeThemeListener = (listener) => { - themeListeners.delete(listener); -}; - -/** - * 通知主题监听器 - */ -const notifyThemeListeners = (theme, resolved) => { - themeListeners.forEach((listener) => { - try { - listener(theme, resolved); - } catch (e) { - console.error('Theme listener error:', e); - } +const notifyGlobalListeners = (theme, resolved) => { + globalThemeListeners.forEach((fn) => { + try { fn(theme, resolved); } catch (e) { console.error('Theme listener error:', e); } }); }; +const addThemeListener = (fn) => { + globalThemeListeners.add(fn); + return () => globalThemeListeners.delete(fn); +}; + +const removeThemeListener = (fn) => { + globalThemeListeners.delete(fn); +}; + +const clearThemeListeners = () => { + globalThemeListeners.clear(); +}; + +// ============ 系统主题监听 ============ + +let _sysWatchUnsub = null; + +const startSystemWatch = () => { + watchSystemTheme(); // 使用兼容的全局单例 +}; + +const stopSystemWatch = () => { + if (_sysWatchUnsub) { _sysWatchUnsub(); _sysWatchUnsub = null; } +}; + +// 保留旧 API 兼容 +const unwatchSystemTheme = stopSystemWatch; + +// ============ 初始化 ============ + +const initTheme = () => { + const savedTheme = loadTheme(); + applyTheme(savedTheme); + startSystemWatch(); +}; + +// ============ 外部主题跟随(v0.1.5 新增) ============ + /** - * 清除所有主题监听器 + * 跟随外部元素的主题 + * 支持三种检测策略: + * 1. data-md-theme 属性(推荐,显式声明) + * 2. CSS 类名(如 theme-dark / theme-light) + * 3. 自定义回调函数 + * + * @param {Object} options + * @param {HTMLElement} [options.element] - 观察的元素(默认 documentElement) + * @param {string} [options.attr='data-theme'] - 检测的属性名 + * @param {string[]} [options.classMap] - 类名映射 { 'theme-dark': 'dark', 'theme-light': 'light' } + * @param {Function} [options.callback] - 自定义回调 (element) => themeName + * @param {Function} onThemeChange - 主题变化回调 (resolvedTheme) => void + * @returns {() => void} 取消跟随函数 */ -export const clearThemeListeners = () => { - themeListeners.clear(); +const followExternalTheme = (options, onThemeChange) => { + if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') { + return () => {}; + } + + const { + element = document.documentElement, + attr = 'data-theme', + classMap = null, + callback = null, + } = options || {}; + + const detect = () => { + if (typeof callback === 'function') { + return callback(element); + } + // 策略1:属性检测 + const attrVal = element.getAttribute(attr); + if (attrVal) return attrVal; + // 策略2:classMap 检测 + if (classMap) { + for (const [cls, theme] of Object.entries(classMap)) { + if (element.classList.contains(cls)) return theme; + } + } + // 策略3:data-md-theme 回退 + const mdTheme = element.getAttribute('data-md-theme'); + if (mdTheme) return mdTheme; + return null; + }; + + let lastTheme = detect(); + if (lastTheme) onThemeChange(lastTheme); + + const observer = new MutationObserver(() => { + const current = detect(); + if (current && current !== lastTheme) { + lastTheme = current; + onThemeChange(current); + } + }); + + observer.observe(element, { + attributes: true, + attributeFilter: [attr, 'class', 'data-md-theme'], + }); + + return () => observer.disconnect(); }; /** - * 注册自定义主题 + * 从父容器继承主题(读取 CSS 变量 + data-md-theme) + * 适用场景:编辑器嵌入已有主题体系的应用时自动适配 + * + * @param {HTMLElement} container - 编辑器容器元素 + * @param {(theme: string) => void} onThemeDetected - 检测到主题的回调 + * @returns {() => void} 取消跟随函数 */ -export const registerTheme = (name, config) => { - const base = THEMES.light; +const adoptFromParent = (container, onThemeDetected) => { + if (typeof document === 'undefined' || !container) return () => {}; + + const detect = () => { + // 向上遍历 DOM 树寻找 data-md-theme 属性 + let el = container.parentElement; + while (el) { + const t = el.getAttribute('data-md-theme'); + if (t) return t; + // 也检测常见类名 + if (el.classList.contains('theme-dark') || el.classList.contains('dark')) return 'dark'; + if (el.classList.contains('theme-light') || el.classList.contains('light')) return 'light'; + el = el.parentElement; + } + // 回退:读取父元素 computed style 中的 --md-bg 判断明暗 + if (container.parentElement) { + const bg = getComputedStyle(container.parentElement).getPropertyValue('--md-bg').trim() + || getComputedStyle(container.parentElement).backgroundColor; + if (bg) { + // 简单亮度判断 + const rgb = bg.match(/\d+/g); + if (rgb && rgb.length >= 3) { + const brightness = (parseInt(rgb[0]) * 299 + parseInt(rgb[1]) * 587 + parseInt(rgb[2]) * 114) / 1000; + return brightness < 128 ? 'dark' : 'light'; + } + } + } + return null; + }; + + const detected = detect(); + if (detected) onThemeDetected(detected); + + // 监听父元素的属性变化 + const observer = new MutationObserver(() => { + const d = detect(); + if (d) onThemeDetected(d); + }); + + let target = container.parentElement; + while (target) { + observer.observe(target, { attributes: true, attributeFilter: ['class', 'data-md-theme', 'data-theme'] }); + target = target.parentElement; + // 只观察向上3层,避免性能问题 + if (target === document.documentElement) break; + } + + return () => observer.disconnect(); +}; + +/** + * 创建一个主题观察者,监听外部主题源变化 + * @param {Function|string} source - 回调函数返回主题名,或 CSS 选择器 + * @param {(theme: string) => void} onChange - 主题变化回调 + * @returns {() => void} 取消观察 + */ +const watch = (source, onChange) => { + if (typeof source === 'function') { + const tick = () => { + try { + const theme = source(); + if (theme) onChange(theme); + } catch (_) {} + }; + tick(); + const id = setInterval(tick, 500); + return () => clearInterval(id); + } + + if (typeof source === 'string' && typeof document !== 'undefined') { + const el = document.querySelector(source); + if (!el) return () => {}; + return followExternalTheme({ element: el, attr: 'data-theme' }, onChange); + } + + return () => {}; +}; + +// ============ 自定义主题注册(v0.1.5: 支持继承) ============ + +/** + * 注册自定义主题 + * @param {string} name - 主题名 + * @param {Object} config - 主题配置 + * @param {string} [config.extends] - 继承的基础主题名(如 'light') + */ +const registerTheme = (name, config = {}) => { + const baseName = config.extends || 'light'; + const base = THEMES[baseName] || THEMES.light; + THEMES[name] = { bg: config.bg || base.bg, text: config.text || base.text, border: config.border || base.border, shadow: config.shadow || base.shadow, hoverShadow: config.hoverShadow || base.hoverShadow, - toolbarBg: config.toolbarBg || config.bg || base.toolbarBg, - textareaBg: config.textareaBg || config.bg || base.textareaBg, - previewBg: config.previewBg || config.bg || base.previewBg, + toolbarBg: config.toolbarBg || base.toolbarBg || base.bg, + textareaBg: config.textareaBg || base.textareaBg || base.bg, + previewBg: config.previewBg || base.previewBg || base.bg, codeBg: config.codeBg || base.codeBg, codeText: config.codeText || base.codeText, accent: config.accent || base.accent, muted: config.muted || base.muted, - // 兼容旧字段 - progressBg: config.progressBg, - closeHoverBg: config.closeHoverBg, + progressBg: config.progressBg || base.progressBg, + closeHoverBg: config.closeHoverBg || base.closeHoverBg, + }; +}; + +const unregisterTheme = (name) => { + if (name === 'light' || name === 'dark' || name === 'auto' || name === 'warm') { + console.warn('Cannot unregister built-in theme:', name); + return; + } + delete THEMES[name]; +}; + +const getAllThemes = () => ({ ...THEMES }); +const getThemeNames = () => Object.keys(THEMES); +const hasTheme = (name) => name in THEMES; + +// ============ 实例级主题管理(v0.1.5 新增) ============ + +/** + * 为编辑器实例创建隔离的主题上下文 + * 每个上下文独立跟踪主题,变更时自动同步 CSS 变量到实例 DOM + * + * @param {Object} editor - 编辑器实例(需有 el / config 属性) + * @returns {Object} 实例主题 API + */ +const createInstanceTheme = (editor) => { + let instanceTheme = editor.config?.theme || globalCurrentTheme || 'auto'; + + /** 应用主题到当前实例的 DOM */ + const apply = (theme) => { + instanceTheme = theme; + const resolved = resolveTheme(theme); + const config = getThemeConfig(theme); + + if (editor.el) { + // 更新 wrapper 类名 + ['me-theme-light', 'me-theme-dark', 'me-theme-warm'].forEach((c) => { + editor.el.classList.remove(c); + }); + if (resolved !== 'auto') { + editor.el.classList.add(`me-theme-${resolved}`); + } + editor.el.setAttribute('data-md-theme', resolved); + + // 设置实例级 CSS 变量(scope 到 wrapper) + setThemeVariables(config, editor.el); + } + + // 触发编辑器实例事件 + if (typeof editor._emit === 'function') { + editor._emit('themeChange', { theme, resolved, config }); + } + + // 强制刷新预览(主题可能影响渲染) + if (typeof editor.refresh === 'function') { + editor.refresh(); + } + + return instanceTheme; + }; + + /** 获取实例当前主题名 */ + const get = () => instanceTheme; + + /** 设置实例主题 */ + const set = (theme) => apply(theme); + + /** 切换 light/dark */ + const toggle = () => { + const resolved = resolveTheme(instanceTheme); + return apply(resolved === 'dark' ? 'light' : 'dark'); + }; + + /** 获取解析后的主题名 */ + const getResolved = () => resolveTheme(instanceTheme); + + /** 获取实例主题配置 */ + const getConfig = () => getThemeConfig(instanceTheme); + + /** 获取实例 CSS 变量导出 */ + const getVars = () => { + if (editor.el) return exportCSSVars(editor.el); + return {}; + }; + + /** 外部主题跟随:同步到外部元素 */ + const syncWithElement = (element, opts = {}) => { + return followExternalTheme( + { element, attr: opts.attr || 'data-theme', classMap: opts.classMap, callback: opts.callback }, + (detected) => apply(detected), + ); + }; + + /** 从父容器继承主题 */ + const adopt = () => { + if (editor.container) { + return adoptFromParent(editor.container, (detected) => apply(detected)); + } + return () => {}; + }; + + /** 监听外部主题变化 */ + const watchExternal = (source) => { + return watch(source, (theme) => apply(theme)); + }; + + // 初始应用 + apply(instanceTheme); + + return { + apply, + get, + set, + toggle, + getResolved, + getConfig, + getVars, + syncWithElement, + adopt, + watch: watchExternal, }; }; /** - * 注销自定义主题 + * 创建独立主题管理器(工厂函数,向后兼容) + * @returns {Object} 主题管理器 */ -export const unregisterTheme = (name) => { - if (name === 'light' || name === 'dark' || name === 'auto') { - console.warn('Cannot unregister built-in theme:', name); - return; - } - - delete THEMES[name]; -}; - -/** - * 获取所有主题 - */ -export const getAllThemes = () => { - return { ...THEMES }; -}; - -/** - * 获取主题名称列表 - */ -export const getThemeNames = () => { - return Object.keys(THEMES); -}; - -/** - * 检查主题是否存在 - */ -export const hasTheme = (name) => { - return name in THEMES; -}; - -/** - * 主题工具 — 代理层 - */ -export const themeUtils = { +const createThemeManager = () => ({ getSystemTheme, resolveTheme, getThemeConfig, @@ -322,69 +626,114 @@ export const themeUtils = { hasTheme, saveTheme, loadTheme, + setThemeVariables, + exportCSSVars, + getCSSVariable, + applyThemeToElement, + followExternalTheme, + adoptFromParent, + watch, + createInstanceTheme, +}); + +// ============ 预设主题 ============ + +const presetThemes = { + light: { name: '浅色', description: '明亮清晰的主题', config: THEMES.light }, + dark: { name: '深色', description: '护眼舒适的暗色主题', config: THEMES.dark }, + auto: { name: '自动', description: '跟随系统主题设置', config: 'auto' }, + warm: { name: '暖色', description: '温馨舒适的暖色主题', config: THEMES.warm }, }; -/** - * 预设主题 - */ -export const presetThemes = { - light: { - name: '浅色', - description: '明亮清晰的主题', - config: THEMES.light, - }, - dark: { - name: '深色', - description: '护眼舒适的暗色主题', - config: THEMES.dark, - }, - auto: { - name: '自动', - description: '跟随系统主题设置', - config: 'auto', - }, - warm: { - name: '暖色', - description: '温馨舒适的暖色主题', - config: THEMES.warm, - }, -}; - -// 注册预设主题 +// 初始化:确保 THEMES 中有预设 Object.entries(presetThemes).forEach(([name, theme]) => { if (name !== 'auto' && theme.config !== 'auto') { THEMES[name] = theme.config; } }); -/** - * 创建主题管理器 - */ -export const createThemeManager = () => { - return { - getSystemTheme, - resolveTheme, - getThemeConfig, - applyTheme, - getCurrentTheme, - getResolvedTheme, - switchTheme, - toggleTheme, - resetToAuto, - initTheme, - watchSystemTheme, - unwatchSystemTheme, - addThemeListener, - removeThemeListener, - clearThemeListeners, - registerTheme, - unregisterTheme, - getAllThemes, - getThemeNames, - hasTheme, - saveTheme, - loadTheme, - }; +// ============ 工具代理层 ============ + +const themeUtils = { + // 系统 + getSystemTheme, + resolveTheme, + getThemeConfig, + watchSystemTheme, + unwatchSystemTheme, + // 全局主题 + applyTheme, + getCurrentTheme, + getResolvedTheme, + switchTheme, + toggleTheme, + resetToAuto, + initTheme, + // 持久化 + saveTheme, + loadTheme, + // 监听 + addThemeListener, + removeThemeListener, + clearThemeListeners, + // 注册 + registerTheme, + unregisterTheme, + getAllThemes, + getThemeNames, + hasTheme, + // CSS 变量 + setThemeVariables, + exportCSSVars, + getCSSVariable, + applyThemeToElement, + // 外部跟随(v0.1.5) + followExternalTheme, + adoptFromParent, + watch, + // 实例主题(v0.1.5) + createInstanceTheme, }; -export { themeUtils as default }; +// ============ 具名导出 ============ + +export { + themeUtils, + themeUtils as default, + getSystemTheme, + resolveTheme, + getThemeConfig, + applyTheme, + getCurrentTheme, + getResolvedTheme, + switchTheme, + toggleTheme, + resetToAuto, + saveTheme, + loadTheme, + initTheme, + watchSystemTheme, + unwatchSystemTheme, + addThemeListener, + removeThemeListener, + clearThemeListeners, + registerTheme, + unregisterTheme, + getAllThemes, + getThemeNames, + hasTheme, + setThemeVariables, + exportCSSVars, + getCSSVariable, + applyThemeToElement, + followExternalTheme, + adoptFromParent, + watch, + createInstanceTheme, + createThemeManager, + presetThemes, +}; + +// 向后兼容别名 +const getTheme = resolveTheme; +export { getTheme }; diff --git a/src/utils.js b/src/utils.js index 54b18d1..eaaf5ae 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,7 +1,7 @@ /** * MetonaEditor Utils - 工具函数 * @module utils - * @version 0.1.4 + * @version 0.1.5 * @description 通用工具函数集合 */ diff --git a/tests/themes.test.js b/tests/themes.test.js index 5aa0753..1728f10 100644 --- a/tests/themes.test.js +++ b/tests/themes.test.js @@ -34,8 +34,16 @@ import { setThemeVariables, presetThemes, createThemeManager, + exportCSSVars, + getCSSVariable, + applyThemeToElement, + followExternalTheme, + adoptFromParent, + watch, + createInstanceTheme, } from '../src/themes.js'; import { THEMES } from '../src/constants.js'; +import { MarkdownEditor } from '../src/core.js'; // 确保 localStorage 存在(jsdom 兼容) if (typeof global.localStorage === 'undefined') { @@ -431,3 +439,384 @@ describe('createThemeManager', () => { expect(m.getCurrentTheme()).toBe('dark'); }); }); + +// ============ v0.1.5 新增测试 ============ + +describe('v0.1.5 resolveTheme auto 修复', () => { + beforeEach(() => { + clearThemeListeners(); + resetToAuto(); + localStorage.clear(); + }); + + test('auto 始终解析为系统主题,不受上次手动设置污染', () => { + switchTheme('dark'); + expect(getCurrentTheme()).toBe('dark'); + // 即使手动设为 dark,resolveTheme('auto') 仍应返回系统主题 + const sys = getSystemTheme(); + expect(resolveTheme('auto')).toBe(sys); + }); + + test('resolveTheme 传入具体主题名原样返回', () => { + expect(resolveTheme('light')).toBe('light'); + expect(resolveTheme('dark')).toBe('dark'); + expect(resolveTheme('warm')).toBe('warm'); + }); + + test('resolveTheme 无参/空值返回系统主题', () => { + const sys = getSystemTheme(); + expect(resolveTheme()).toBe(sys); + expect(resolveTheme(null)).toBe(sys); + expect(resolveTheme('')).toBe(sys); + }); +}); + +describe('v0.1.5 exportCSSVars / getCSSVariable', () => { + test('exportCSSVars 返回 CSS 变量对象', () => { + applyTheme('light'); + const vars = exportCSSVars(); + expect(vars).toHaveProperty('--md-bg'); + expect(vars).toHaveProperty('--md-accent'); + }); + + test('exportCSSVars 从指定元素读取', () => { + const div = document.createElement('div'); + div.style.setProperty('--md-bg', '#fff'); + document.body.appendChild(div); + const vars = exportCSSVars(div); + expect(vars['--md-bg']).toBe('#fff'); + document.body.removeChild(div); + }); + + test('getCSSVariable 获取单个变量', () => { + applyTheme('light'); + const bg = getCSSVariable('--md-bg'); + expect(typeof bg).toBe('string'); + expect(bg.length).toBeGreaterThan(0); + }); + + test('getCSSVariable 自动补全 --md- 前缀', () => { + applyTheme('light'); + const accent = getCSSVariable('accent'); + expect(typeof accent).toBe('string'); + expect(accent.length).toBeGreaterThan(0); + }); +}); + +describe('v0.1.5 applyThemeToElement', () => { + test('应用主题到指定元素不改变全局状态', () => { + switchTheme('light'); + const div = document.createElement('div'); + document.body.appendChild(div); + + applyThemeToElement('dark', div); + expect(div.getAttribute('data-md-theme')).toBe('dark'); + // 全局主题不变 + expect(getCurrentTheme()).toBe('light'); + + document.body.removeChild(div); + }); + + test('应用 auto 主题正确解析', () => { + const div = document.createElement('div'); + document.body.appendChild(div); + applyThemeToElement('auto', div); + const resolved = resolveTheme('auto'); + expect(div.getAttribute('data-md-theme')).toBe(resolved); + document.body.removeChild(div); + }); +}); + +describe('v0.1.5 主题继承 registerTheme extends', () => { + test('extends 继承基础主题', () => { + registerTheme('ocean', { + extends: 'dark', + accent: '#00ddff', + }); + expect(hasTheme('ocean')).toBe(true); + const cfg = getThemeConfig('ocean'); + expect(cfg.accent).toBe('#00ddff'); + // 从 dark 继承 + expect(cfg.bg).toBe(THEMES.dark.bg); + expect(cfg.text).toBe(THEMES.dark.text); + unregisterTheme('ocean'); + }); + + test('默认 extends light', () => { + registerTheme('minimal', { accent: '#ff0000' }); + const cfg = getThemeConfig('minimal'); + expect(cfg.bg).toBe(THEMES.light.bg); + unregisterTheme('minimal'); + }); +}); + +describe('v0.1.5 createInstanceTheme', () => { + test('创建实例主题上下文', () => { + const div = document.createElement('div'); + document.body.appendChild(div); + const mockEditor = { + el: div, + config: { theme: 'dark' }, + _emit: jest.fn(), + refresh: jest.fn(), + }; + const ctx = createInstanceTheme(mockEditor); + expect(ctx.get()).toBe('dark'); + expect(ctx.getResolved()).toBe('dark'); + expect(typeof ctx.set).toBe('function'); + expect(typeof ctx.toggle).toBe('function'); + expect(typeof ctx.getConfig).toBe('function'); + expect(typeof ctx.getVars).toBe('function'); + expect(typeof ctx.syncWithElement).toBe('function'); + expect(typeof ctx.adopt).toBe('function'); + expect(typeof ctx.watch).toBe('function'); + document.body.removeChild(div); + }); + + test('实例主题 set 触发 _emit themeChange', () => { + const div = document.createElement('div'); + document.body.appendChild(div); + const mockEditor = { + el: div, + config: { theme: 'light' }, + _emit: jest.fn(), + refresh: jest.fn(), + }; + const ctx = createInstanceTheme(mockEditor); + ctx.set('dark'); + expect(mockEditor._emit).toHaveBeenCalledWith('themeChange', expect.objectContaining({ + theme: 'dark', + resolved: 'dark', + })); + expect(mockEditor.refresh).toHaveBeenCalled(); + document.body.removeChild(div); + }); + + test('实例主题 toggle 切换 light/dark', () => { + const div = document.createElement('div'); + document.body.appendChild(div); + const mockEditor = { + el: div, + config: { theme: 'light' }, + _emit: jest.fn(), + refresh: jest.fn(), + }; + const ctx = createInstanceTheme(mockEditor); + ctx.toggle(); + expect(ctx.get()).toBe('dark'); + ctx.toggle(); + expect(ctx.get()).toBe('light'); + document.body.removeChild(div); + }); + + test('getConfig 返回主题配置对象', () => { + const div = document.createElement('div'); + document.body.appendChild(div); + const mockEditor = { + el: div, + config: { theme: 'light' }, + _emit: jest.fn(), + refresh: jest.fn(), + }; + const ctx = createInstanceTheme(mockEditor); + const cfg = ctx.getConfig(); + expect(cfg).toHaveProperty('bg'); + expect(cfg).toHaveProperty('accent'); + document.body.removeChild(div); + }); + + test('getVars 返回实例 CSS 变量', () => { + const div = document.createElement('div'); + document.body.appendChild(div); + const mockEditor = { + el: div, + config: { theme: 'light' }, + _emit: jest.fn(), + refresh: jest.fn(), + }; + const ctx = createInstanceTheme(mockEditor); + applyThemeToElement('light', div); + const vars = ctx.getVars(); + expect(vars).toHaveProperty('--md-bg'); + document.body.removeChild(div); + }); +}); + +describe('v0.1.5 followExternalTheme', () => { + test('通过 data-theme 属性跟随', () => { + const div = document.createElement('div'); + div.setAttribute('data-theme', 'dark'); + document.body.appendChild(div); + + let detected = null; + const unsub = followExternalTheme({ element: div }, (theme) => { detected = theme; }); + expect(detected).toBe('dark'); + + div.setAttribute('data-theme', 'light'); + // 手动触发 MutationObserver(jsdom 中需手动 dispatch) + const observer = new MutationObserver(() => {}); + // 注:jsdom 环境 MutationObserver 行为受限,此处只验证初始化回调 + unsub(); + document.body.removeChild(div); + }); + + test('通过 classMap 跟随', () => { + const div = document.createElement('div'); + div.className = 'theme-dark'; + document.body.appendChild(div); + + let detected = null; + followExternalTheme( + { element: div, classMap: { 'theme-dark': 'dark', 'theme-light': 'light' } }, + (theme) => { detected = theme; }, + ); + expect(detected).toBe('dark'); + document.body.removeChild(div); + }); + + test('通过 callback 跟随', () => { + const div = document.createElement('div'); + document.body.appendChild(div); + + let detected = null; + followExternalTheme( + { element: div, callback: () => 'warm' }, + (theme) => { detected = theme; }, + ); + expect(detected).toBe('warm'); + document.body.removeChild(div); + }); + + test('返回取消跟随函数', () => { + const div = document.createElement('div'); + document.body.appendChild(div); + const unsub = followExternalTheme({ element: div }, () => {}); + expect(typeof unsub).toBe('function'); + unsub(); + document.body.removeChild(div); + }); +}); + +describe('v0.1.5 adoptFromParent', () => { + test('从父容器 data-md-theme 继承', () => { + const parent = document.createElement('div'); + parent.setAttribute('data-md-theme', 'dark'); + const child = document.createElement('div'); + parent.appendChild(child); + document.body.appendChild(parent); + + let detected = null; + const unsub = adoptFromParent(child, (theme) => { detected = theme; }); + expect(detected).toBe('dark'); + unsub(); + document.body.removeChild(parent); + }); + + test('从父容器 class 继承', () => { + const parent = document.createElement('div'); + parent.className = 'dark'; + const child = document.createElement('div'); + parent.appendChild(child); + document.body.appendChild(parent); + + let detected = null; + adoptFromParent(child, (theme) => { detected = theme; }); + expect(detected).toBe('dark'); + document.body.removeChild(parent); + }); + + test('容器无父元素不抛错', () => { + const orphan = document.createElement('div'); + let detected = null; + expect(() => adoptFromParent(orphan, (t) => { detected = t; })).not.toThrow(); + }); + + test('返回取消函数', () => { + const parent = document.createElement('div'); + const child = document.createElement('div'); + parent.appendChild(child); + document.body.appendChild(parent); + const unsub = adoptFromParent(child, () => {}); + expect(typeof unsub).toBe('function'); + unsub(); + document.body.removeChild(parent); + }); +}); + +describe('v0.1.5 watch 外部主题源', () => { + test('函数源:回调被调用', () => { + let detected = null; + const unsub = watch(() => 'dark', (theme) => { detected = theme; }); + expect(detected).toBe('dark'); + unsub(); + }); + + test('返回取消函数', () => { + const unsub = watch(() => 'light', () => {}); + expect(typeof unsub).toBe('function'); + unsub(); + }); +}); + +describe('v0.1.5 MarkdownEditor 实例主题 API', () => { + test('setTheme / getTheme 实例方法', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { theme: 'light' }); + + expect(ed.getTheme()).toBe('light'); + ed.setTheme('dark'); + expect(ed.getTheme()).toBe('dark'); + // el 类名应更新 + expect(ed.el.className).toContain('me-theme-dark'); + + ed.destroy(); + }); + + test('getThemeContext 返回实例主题上下文', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, {}); + + const ctx = ed.getThemeContext(); + expect(ctx).not.toBeNull(); + expect(typeof ctx.set).toBe('function'); + expect(typeof ctx.get).toBe('function'); + + ed.destroy(); + }); + + test('多实例独立主题互不干扰', () => { + document.body.innerHTML = ''; + const c1 = document.createElement('div'); + const c2 = document.createElement('div'); + document.body.appendChild(c1); + document.body.appendChild(c2); + const ed1 = new MarkdownEditor(c1, { theme: 'light' }); + const ed2 = new MarkdownEditor(c2, { theme: 'dark' }); + + expect(ed1.getTheme()).toBe('light'); + expect(ed2.getTheme()).toBe('dark'); + + ed1.setTheme('warm'); + expect(ed1.getTheme()).toBe('warm'); + expect(ed2.getTheme()).toBe('dark'); // ed2 不受影响 + + ed1.destroy(); + ed2.destroy(); + }); + + test('setTheme 触发 themeChange 事件', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, {}); + const handler = jest.fn(); + ed.on('themeChange', handler); + ed.setTheme('dark'); + expect(handler).toHaveBeenCalled(); + ed.destroy(); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index 06df18e..d33cf76 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,4 +1,4 @@ -// Type definitions for MetonaEditor v0.1.4 +// Type definitions for MetonaEditor v0.1.5 // Project: https://git.metona.cn/MetonaTeam/MetonaEditor // Author: thzxx @@ -201,6 +201,21 @@ export class MarkdownEditor { getStats(): EditorStats; getStatus(): EditorStatus; + // 主题(v0.1.5: 实例级主题管理) + setTheme(theme: ThemeName): this; + getTheme(): string; + getThemeContext(): { + get(): string; + set(theme: string): string; + toggle(): string; + getResolved(): string; + getConfig(): object; + getVars(): object; + syncWithElement(element: HTMLElement, opts?: object): () => void; + adopt(): () => void; + watch(source: Function | string): () => void; + } | null; + // 插件 use(plugin: string | PluginObject, options?: object): this; getPlugins(): PluginObject[]; @@ -307,7 +322,7 @@ export const themeUtils: { getCurrentTheme(): string; getResolvedTheme(): string; applyTheme(theme: ThemeName): void; - registerTheme(name: string, config: object): void; + registerTheme(name: string, config: { extends?: string; [key: string]: any }): void; unregisterTheme(name: string): void; getAllThemes(): object; getThemeNames(): string[]; @@ -317,6 +332,14 @@ export const themeUtils: { addThemeListener(fn: (theme: string, resolved: string) => void): () => void; removeThemeListener(fn: (theme: string, resolved: string) => void): void; clearThemeListeners(): void; + // v0.1.5 新增 + exportCSSVars(el?: HTMLElement): Record; + 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; };