release: v0.1.6 — plugin v2, i18n instance isolation, shortcuts, toolbar, toast
feat(plugins): plugin system v2
- depends: declarative plugin dependencies with topological sort (Kahn algorithm)
- async plugins: install() returning Promise auto-await
- editor.unuse(name): uninstall individual plugins
- pluginUtils.validateConfig(schema, config): configuration validation
- plugin priority field controls install order within same dependency level
feat(i18n): instance-level locale isolation, pluralization, dynamic loading
- createInstanceI18n(): per-editor independent locale contexts
- editor.setLocale()/getLocale()/t() instance methods
- Plural rules: t('items', { count: 5 }) auto-selects one/other/few/many
- i18nUtils.loadRemote(url, locale): fetch translation packs from remote
- editor.on('localeChange') event
feat(core): toolbar & shortcut customization, context menu, toast
- editor.registerShortcut(combo, handler): custom keyboard shortcuts
- editor.configureToolbar(tools): dynamic toolbar rebuild
- editor.removeToolbarButton(action): remove single button
- editor.registerContextMenu(items): right-click context menu
- editor.toast(msg, {type, duration, animation}): toast notifications
style: toast notifications, context menu, dropdown CSS
- .me-toast with success/error/warning/info types, fade/slide animations
- .me-context-menu with items, separators, shortcut hints
test: 22 new tests covering plugin deps, unuse, shortcuts, toast, i18n plural (521 total)
chore: bump version 0.1.5 → 0.1.6 across all files, site, types
This commit is contained in:
+216
-10
@@ -1,20 +1,29 @@
|
||||
/**
|
||||
* MetonaEditor Core - 编辑器核心
|
||||
* @module core
|
||||
* @version 0.1.5
|
||||
* @version 0.1.6
|
||||
* @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换
|
||||
* v0.1.6: 插件依赖拓扑排序、实例 i18n、快捷键/工具栏定制、右键菜单、Toast
|
||||
*/
|
||||
|
||||
import { generateId, escapeHTML, isBrowser } from './utils.js';
|
||||
import { DEFAULTS, ICONS, THEMES } from './constants.js';
|
||||
import { injectStyles } from './styles.js';
|
||||
import { t as i18nT, getCurrentLocale, getLocaleDirection } from './i18n.js';
|
||||
import { t as i18nT, getCurrentLocale, getLocaleDirection, createInstanceI18n } from './i18n.js';
|
||||
import { parseMarkdown } from './parser.js';
|
||||
import { presetPlugins } from './plugins.js';
|
||||
import { presetPlugins, topologicalSort } from './plugins.js';
|
||||
import { resolveTheme, getThemeConfig, setThemeVariables, createInstanceTheme } from './themes.js';
|
||||
|
||||
const MODES = ['edit', 'split', 'preview'];
|
||||
|
||||
/** Toast 图标 */
|
||||
const TOAST_ICONS = {
|
||||
success: '✓',
|
||||
error: '✗',
|
||||
warning: '⚠',
|
||||
info: 'ℹ',
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析主题名称为实际主题(auto -> light/dark)
|
||||
*/
|
||||
@@ -89,6 +98,9 @@ class MarkdownEditor {
|
||||
this._fullscreen = false;
|
||||
this._destroyed = false;
|
||||
this._lastRenderedValue = null;
|
||||
this._shortcuts = []; // v0.1.6: 快捷键注册表
|
||||
this._contextMenuItems = []; // v0.1.6: 右键菜单项
|
||||
this._customActions = {}; // 自定义工具栏动作
|
||||
|
||||
// 渲染函数:自定义覆盖内置解析器
|
||||
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
|
||||
@@ -99,12 +111,14 @@ class MarkdownEditor {
|
||||
injectStyles();
|
||||
this._buildDOM();
|
||||
|
||||
// 实例级主题管理(v0.1.5: 隔离 CSS 变量到 wrapper,支持独立主题)
|
||||
// 实例级主题管理(v0.1.5)
|
||||
this._themeCtx = createInstanceTheme(this);
|
||||
|
||||
// 向后兼容:用户显式传 theme 时同步到全局并持久化
|
||||
// 实例级 i18n 管理(v0.1.6)
|
||||
this._i18nCtx = createInstanceI18n(this);
|
||||
|
||||
// 向后兼容:用户显式传 theme 时同步到全局
|
||||
if (options.theme) {
|
||||
// 全局同步(保留原有行为)
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-md-theme', resolveTheme(options.theme));
|
||||
}
|
||||
@@ -123,9 +137,14 @@ class MarkdownEditor {
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
|
||||
// 安装配置中的插件
|
||||
// 安装配置中的插件(v0.1.6: 拓扑排序)
|
||||
if (Array.isArray(this.config.plugins)) {
|
||||
this.config.plugins.forEach((p) => this.use(p));
|
||||
const plugins = this.config.plugins.map((p) => {
|
||||
if (typeof p === 'string') return { ...presetPlugins[p], _isStringRef: true };
|
||||
return p;
|
||||
}).filter(Boolean);
|
||||
const sorted = topologicalSort(plugins);
|
||||
sorted.forEach((p) => this.use(p));
|
||||
}
|
||||
|
||||
if (this.config.autofocus && !this.config.readOnly) this.focus();
|
||||
@@ -352,6 +371,24 @@ class MarkdownEditor {
|
||||
return;
|
||||
}
|
||||
|
||||
// v0.1.6: 检查自定义快捷键注册表
|
||||
if (this._shortcuts.length) {
|
||||
for (const sc of this._shortcuts) {
|
||||
const ctrlOk = sc.ctrl ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey;
|
||||
const shiftOk = sc.shift ? e.shiftKey : !e.shiftKey;
|
||||
const altOk = sc.alt ? e.altKey : !e.altKey;
|
||||
if (e.key.toLowerCase() === sc.key && ctrlOk && shiftOk && altOk) {
|
||||
e.preventDefault();
|
||||
if (typeof sc.handler === 'string') {
|
||||
this.exec(sc.handler);
|
||||
} else if (typeof sc.handler === 'function') {
|
||||
try { sc.handler(this); } catch (err) { console.error('Shortcut handler error:', err); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (!mod) return;
|
||||
|
||||
@@ -890,7 +927,7 @@ class MarkdownEditor {
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装插件
|
||||
* 安装插件(v0.1.6: 支持异步 install)
|
||||
* @param {string|Object} plugin - 预设插件名或插件对象
|
||||
* @param {Object} options - 插件配置
|
||||
*/
|
||||
@@ -910,15 +947,40 @@ class MarkdownEditor {
|
||||
const merged = { ...p, ...options };
|
||||
if (typeof merged.install === 'function') {
|
||||
try {
|
||||
merged.install(this);
|
||||
const result = merged.install(this);
|
||||
// 异步插件支持
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.catch((e) => {
|
||||
console.error(`MeEditor: async plugin "${merged.name || 'custom'}" install error:`, e);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`MeEditor: plugin "${merged.name || 'custom'}" install error:`, e);
|
||||
}
|
||||
}
|
||||
// 直接 push 原对象(保持运行时属性引用一致)
|
||||
this._plugins.push(merged);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载插件(v0.1.6 新增)
|
||||
* @param {string} name - 插件名
|
||||
*/
|
||||
unuse(name) {
|
||||
const idx = this._plugins.findIndex((p) => p.name === name);
|
||||
if (idx === -1) {
|
||||
console.warn(`MeEditor: plugin "${name}" not found`);
|
||||
return this;
|
||||
}
|
||||
const plugin = this._plugins[idx];
|
||||
if (typeof plugin.destroy === 'function') {
|
||||
try { plugin.destroy(this); } catch (e) { console.error(`MeEditor: plugin "${name}" destroy error:`, e); }
|
||||
}
|
||||
this._plugins.splice(idx, 1);
|
||||
return this;
|
||||
}
|
||||
|
||||
getPlugins() { return [...this._plugins]; }
|
||||
|
||||
/**
|
||||
@@ -944,6 +1006,142 @@ class MarkdownEditor {
|
||||
return this;
|
||||
}
|
||||
|
||||
// ============ v0.1.6: 快捷键注册 ============
|
||||
|
||||
/**
|
||||
* 注册自定义快捷键
|
||||
* @param {string} combo - 组合键如 'Ctrl+B' / 'Ctrl+Shift+K' / 'Alt+1'
|
||||
* @param {Function|string} handler - 回调函数或 exec action 名
|
||||
* @param {string} [description] - 描述
|
||||
*/
|
||||
registerShortcut(combo, handler, description = '') {
|
||||
if (!combo) return this;
|
||||
|
||||
// 解析组合键
|
||||
const parts = combo.toLowerCase().split('+');
|
||||
const key = parts.pop();
|
||||
const ctrl = parts.includes('ctrl') || parts.includes('cmd');
|
||||
const shift = parts.includes('shift');
|
||||
const alt = parts.includes('alt');
|
||||
const meta = parts.includes('meta');
|
||||
|
||||
this._shortcuts.push({ combo, key, ctrl, shift, alt, meta, handler, description });
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销快捷键
|
||||
* @param {string} combo - 组合键字符串
|
||||
*/
|
||||
unregisterShortcut(combo) {
|
||||
this._shortcuts = this._shortcuts.filter((s) => s.combo !== combo);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已注册的快捷键列表
|
||||
*/
|
||||
getShortcuts() {
|
||||
return [...this._shortcuts];
|
||||
}
|
||||
|
||||
// ============ v0.1.6: 工具栏动态配置 ============
|
||||
|
||||
/**
|
||||
* 动态重建工具栏按钮
|
||||
* @param {Array} tools - 按钮数组(同 config.toolbar 格式)
|
||||
*/
|
||||
configureToolbar(tools) {
|
||||
if (!this.toolbarEl) return this;
|
||||
this.config.toolbar = tools;
|
||||
this.toolbarEl.innerHTML = '';
|
||||
this._buildToolbar();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从工具栏移除指定按钮
|
||||
* @param {string} action - 按钮 action 名
|
||||
*/
|
||||
removeToolbarButton(action) {
|
||||
if (!this.toolbarEl) return this;
|
||||
const btn = this.toolbarEl.querySelector(`.me-btn[data-action="${action}"], .me-btn[data-mode="${action}"]`);
|
||||
if (btn) btn.remove();
|
||||
return this;
|
||||
}
|
||||
|
||||
// ============ v0.1.6: 右键菜单 ============
|
||||
|
||||
/**
|
||||
* 注册右键上下文菜单
|
||||
* @param {Array} items - [{ label, action, shortcut?, type?:'separator' }]
|
||||
*/
|
||||
registerContextMenu(items = []) {
|
||||
this._contextMenuItems = items;
|
||||
return this;
|
||||
}
|
||||
|
||||
// ============ v0.1.6: Toast 通知 ============
|
||||
|
||||
/**
|
||||
* 显示 Toast 通知
|
||||
* @param {string} message - 消息内容
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.type='info'] - success / error / warning / info
|
||||
* @param {number} [opts.duration=3000] - 自动消失时间(ms),0 不自动消失
|
||||
* @param {string} [opts.animation='fade'] - 动画类型
|
||||
*/
|
||||
toast(message, opts = {}) {
|
||||
if (!this.el || typeof document === 'undefined') return this;
|
||||
const { type = 'info', duration = 3000, animation = 'fade' } = opts;
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `me-toast me-toast-${type} me-toast-${animation}`;
|
||||
toast.innerHTML = `<span class="me-toast-icon">${TOAST_ICONS[type] || ''}</span><span class="me-toast-msg">${escapeHTML(String(message))}</span>`;
|
||||
this.el.appendChild(toast);
|
||||
|
||||
// 入场动画
|
||||
requestAnimationFrame(() => { toast.classList.add('me-toast-enter'); });
|
||||
|
||||
const remove = () => {
|
||||
toast.classList.remove('me-toast-enter');
|
||||
toast.classList.add('me-toast-leave');
|
||||
setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
|
||||
};
|
||||
|
||||
if (duration > 0) setTimeout(remove, duration);
|
||||
|
||||
// 点击关闭
|
||||
toast.addEventListener('click', remove);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ============ v0.1.6: 实例级 i18n ============
|
||||
|
||||
/**
|
||||
* 设置实例语言
|
||||
*/
|
||||
setLocale(locale) {
|
||||
if (this._i18nCtx) this._i18nCtx.set(locale);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实例当前语言
|
||||
*/
|
||||
getLocale() {
|
||||
if (this._i18nCtx) return this._i18nCtx.get();
|
||||
return getCurrentLocale();
|
||||
}
|
||||
|
||||
/**
|
||||
* 实例级翻译函数
|
||||
*/
|
||||
t(key, params) {
|
||||
if (this._i18nCtx) return this._i18nCtx.t(key, params);
|
||||
return i18nT(key, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁编辑器
|
||||
*/
|
||||
@@ -956,6 +1154,14 @@ class MarkdownEditor {
|
||||
|
||||
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} });
|
||||
this._cleanups = [];
|
||||
this._shortcuts = [];
|
||||
this._contextMenuItems = [];
|
||||
this._customActions = {};
|
||||
|
||||
// 移除所有 toast
|
||||
if (this.el) {
|
||||
this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
|
||||
}
|
||||
|
||||
// 销毁插件
|
||||
this._plugins.forEach((p) => {
|
||||
|
||||
Reference in New Issue
Block a user