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:
2026-07-24 10:17:57 +08:00
parent d41da3c204
commit 45ed8d5351
20 changed files with 1038 additions and 405 deletions
+199 -145
View File
@@ -1,25 +1,22 @@
/**
* MetonaEditor Plugins - 插件系统
* MetonaEditor Plugins 插件系统 v2
* @module plugins
* @version 0.1.5
* @description 插件管理器 + 预设插件autoSave / exportTool / searchReplace
* @version 0.1.6
* @description 插件管理器 + 预设插件 + 依赖/生命周期/异步/卸载
*
* 插件约定
* {
* name: string,
* description?: string,
* install(editor, options): void, // 安装时调用,editor 为 MarkdownEditor 实例
* destroy(editor): void // 卸载时调用,清理事件与 DOM
* }
* install 的 this 指向插件对象本身(core.js 通过 merged.install(editor) 调用),
* 因此可在 this 上存放运行时状态(如定时器、DOM 引用)。
* v0.1.6 增强
* - depends: 声明插件依赖,自动拓扑排序安装
* - 生命周期钩子: beforeInstall/afterInstall/beforeDestroy(静态 + 实例级)
* - 异步插件: install 返回 Promise 时自动 await
* - editor.unuse(name): 卸载单个已安装插件
* - pluginUtils.validateConfig(): schema 配置校验
* - priority: 数字越大越先安装
*/
import { t } from './i18n.js';
/**
* 插件管理器(独立于编辑器实例,用于全局注册与查询;编辑器实例级安装由 core.js 的 use() 负责)
*/
// ============ 插件管理器 ============
class PluginManager {
constructor() {
this.plugins = new Map();
@@ -34,45 +31,149 @@ class PluginManager {
console.error(`MeEditor: invalid plugin "${name}"`);
return this;
}
this.plugins.set(name, { name, ...plugin, enabled: true });
this.plugins.set(name, {
name,
version: plugin.version || '0.0.0',
description: plugin.description || '',
depends: plugin.depends || [],
priority: plugin.priority || 0,
...plugin,
enabled: true,
});
return this;
}
unregister(name) {
this.plugins.delete(name);
return this;
}
get(name) { return this.plugins.get(name) || null; }
has(name) { return this.plugins.has(name); }
getAll() { return Array.from(this.plugins.values()); }
getNames() { return Array.from(this.plugins.keys()); }
enable(name) { const p = this.plugins.get(name); if (p) p.enabled = true; return this; }
disable(name) { const p = this.plugins.get(name); if (p) p.enabled = false; return this; }
isEnabled(name){ const p = this.plugins.get(name); return p ? p.enabled : false; }
destroy() {
this.plugins.clear();
}
unregister(name) { this.plugins.delete(name); return this; }
get(name) { return this.plugins.get(name) || null; }
has(name) { return this.plugins.has(name); }
getAll() { return Array.from(this.plugins.values()); }
getNames() { return Array.from(this.plugins.keys()); }
enable(name) { const p = this.plugins.get(name); if (p) p.enabled = true; return this; }
disable(name) { const p = this.plugins.get(name); if (p) p.enabled = false; return this; }
isEnabled(name) { const p = this.plugins.get(name); return p ? p.enabled : false; }
destroy() { this.plugins.clear(); }
}
const defaultPluginManager = new PluginManager();
// ============ 依赖拓扑排序 ============
/**
* 对插件列表按依赖关系拓扑排序(Kahn 算法)
* @param {Object[]} plugins - 插件对象数组
* @returns {Object[]} 排序后的插件数组
*/
const topologicalSort = (plugins) => {
const map = new Map();
plugins.forEach((p) => map.set(p.name, p));
const inDegree = new Map();
const adj = new Map();
plugins.forEach((p) => {
inDegree.set(p.name, 0);
adj.set(p.name, []);
});
plugins.forEach((p) => {
(p.depends || []).forEach((dep) => {
if (map.has(dep)) {
adj.get(dep).push(p.name);
inDegree.set(p.name, (inDegree.get(p.name) || 0) + 1);
} else {
console.warn(`MeEditor: plugin "${p.name}" depends on unknown "${dep}"`);
}
});
});
const queue = [];
inDegree.forEach((deg, name) => { if (deg === 0) queue.push(name); });
const sorted = [];
while (queue.length) {
// 同层级按 priority 降序
queue.sort((a, b) => (map.get(b).priority || 0) - (map.get(a).priority || 0));
const name = queue.shift();
sorted.push(map.get(name));
(adj.get(name) || []).forEach((n) => {
inDegree.set(n, inDegree.get(n) - 1);
if (inDegree.get(n) === 0) queue.push(n);
});
}
if (sorted.length !== plugins.length) {
console.warn('MeEditor: circular dependency detected in plugins, falling back to original order');
return plugins;
}
return sorted;
};
// ============ 配置校验 ============
/**
* 校验插件配置是否符合 schema
* @param {Object} schema - { key: { type, required, default, enum, validator } }
* @param {Object} config - 用户传入的配置
* @returns {{ valid: boolean, errors: string[], patched: Object }}
*/
const validateConfig = (schema = {}, config = {}) => {
const errors = [];
const patched = { ...config };
Object.entries(schema).forEach(([key, rule]) => {
const val = config[key];
// required
if (rule.required && (val === undefined || val === null)) {
errors.push(`"${key}" is required`);
return;
}
// default
if (val === undefined && rule.default !== undefined) {
patched[key] = rule.default;
return;
}
// type
if (val !== undefined && rule.type) {
const actual = Array.isArray(val) ? 'array' : typeof val;
if (actual !== rule.type) {
errors.push(`"${key}" expected ${rule.type}, got ${actual}`);
}
}
// enum
if (val !== undefined && rule.enum && !rule.enum.includes(val)) {
errors.push(`"${key}" must be one of [${rule.enum.join(', ')}]`);
}
// custom validator
if (val !== undefined && typeof rule.validator === 'function') {
try {
const result = rule.validator(val);
if (result !== true) errors.push(`"${key}": ${result}`);
} catch (e) {
errors.push(`"${key}": ${e.message}`);
}
}
});
return { valid: errors.length === 0, errors, patched };
};
// ============ 预设插件 ============
/**
* 自动保存插件
* 将编辑器内容定时 / 失焦 / 保存时写入 localStorage,并提供草稿恢复与清除。
*
* 配置:
* key: 存储 key,默认 'me-draft-' + editor.id
* delay: 防抖延迟(ms),默认 1000
*/
const autoSavePlugin = {
name: 'autoSave',
version: '0.1.0',
description: '自动保存到 localStorage,支持草稿恢复',
key: null,
delay: 1000,
priority: 100,
depends: [],
_timer: null,
_onInput: null,
@@ -99,7 +200,7 @@ const autoSavePlugin = {
this._save = save;
this._onInput = () => {
if (this._timer) clearTimeout(this._timer);
this._timer = setTimeout(save, this.delay);
this._timer = setTimeout(save, this.delay || 1000);
};
this._onBlur = save;
this._onSave = save;
@@ -108,18 +209,15 @@ const autoSavePlugin = {
editor.on('blur', this._onBlur);
editor.on('save', this._onSave);
// 暴露草稿 API
editor.restoreDraft = () => {
try {
const v = localStorage.getItem(key);
if (v != null && typeof editor.setValue === 'function') {
editor.setValue(v);
}
if (v != null && typeof editor.setValue === 'function') editor.setValue(v);
return v;
} catch (e) { return null; }
};
editor.clearDraft = () => {
try { localStorage.removeItem(key); } catch (e) {}
try { localStorage.removeItem(key); } catch (_) {}
return editor;
};
editor.getDraftKey = () => key;
@@ -133,15 +231,22 @@ const autoSavePlugin = {
if (this._onSave) editor.off('save', this._onSave);
}
},
// 配置 schema
configSchema: {
key: { type: 'string' },
delay: { type: 'number', default: 1000 },
},
};
/**
* 导出工具插件
* 提供 .md / .html 文件下载。
*/
const exportToolPlugin = {
name: 'exportTool',
version: '0.1.0',
description: '导出 Markdown / HTML 文件',
priority: 50,
install(editor) {
if (!editor || typeof editor.getValue !== 'function') return;
@@ -174,18 +279,7 @@ const exportToolPlugin = {
const title = opts.title || 'Document';
const css = opts.css || '';
const body = typeof editor.getHTML === 'function' ? editor.getHTML() : '';
const html = `<!DOCTYPE html>
<html lang="${opts.lang || 'zh-CN'}">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>${title}</title>
${css ? `<style>${css}</style>` : ''}
</head>
<body>
${body}
</body>
</html>`;
const html = `<!DOCTYPE html>\n<html lang="${opts.lang || 'zh-CN'}">\n<head>\n<meta charset="utf-8"/>\n<meta name="viewport" content="width=device-width, initial-scale=1"/>\n<title>${title}</title>\n${css ? `<style>${css}</style>` : ''}\n</head>\n<body>\n${body}\n</body>\n</html>`;
download(filename || `metona-${stamp()}.html`, html, 'text/html');
return editor;
};
@@ -196,35 +290,28 @@ ${body}
/**
* 查找替换插件
* Ctrl+F 唤出浮动搜索条,支持上一个 / 下一个 / 替换 / 全部替换。
*/
const searchReplacePlugin = {
name: 'searchReplace',
description: '查找替换(Ctrl+F',
version: '0.1.0',
description: '查找替换(Ctrl+F / Ctrl+H',
priority: 80,
_onKeydown: null,
_panel: null,
_cleanup: null,
install(editor) {
if (!editor || !editor.textarea) return;
if (typeof document === 'undefined') return;
if (!editor || !editor.textarea || typeof document === 'undefined') return;
this._injectStyle();
this._onKeydown = (e) => {
const mod = e.ctrlKey || e.metaKey;
if (!mod) return;
const k = e.key.toLowerCase();
if (k === 'f') {
e.preventDefault();
this._open(editor);
} else if (k === 'h') {
e.preventDefault();
this._open(editor, true);
} else if (k === 'escape' && this._panel) {
this._close(editor);
}
if (k === 'f') { e.preventDefault(); this._open(editor); }
else if (k === 'h') { e.preventDefault(); this._open(editor, true); }
else if (k === 'escape' && this._panel) { this._close(editor); }
};
editor.textarea.addEventListener('keydown', this._onKeydown);
},
@@ -233,16 +320,7 @@ const searchReplacePlugin = {
if (document.getElementById('me-search-style')) return;
const style = document.createElement('style');
style.id = 'me-search-style';
style.textContent = `
.me-search{position:absolute;top:8px;right:12px;z-index:20;display:flex;flex-direction:column;gap:6px;padding:8px;background:var(--md-toolbar-bg, #f8f9fa);border:1px solid var(--md-border, rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);font-size:13px;min-width:280px}
.me-search-row{display:flex;gap:4px;align-items:center}
.me-search input{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--md-border, rgba(0,0,0,0.15));border-radius:4px;background:var(--md-textarea-bg, #fff);color:var(--md-text, #1f2937);font-size:13px}
.me-search input:focus{outline:none;border-color:var(--md-accent, #3b82f6)}
.me-search button{padding:4px 8px;border:1px solid var(--md-border, rgba(0,0,0,0.15));background:var(--md-bg, #fff);color:var(--md-text, #1f2937);border-radius:4px;cursor:pointer;font-size:12px;line-height:1}
.me-search button:hover{background:var(--md-accent, #3b82f6);color:#fff;border-color:var(--md-accent, #3b82f6)}
.me-search .me-search-count{color:var(--md-muted, #6b7280);font-size:12px;min-width:60px;text-align:center}
.me-search .me-search-close{padding:2px 6px}
`;
style.textContent = `.me-search{position:absolute;top:8px;right:12px;z-index:20;display:flex;flex-direction:column;gap:6px;padding:8px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);font-size:13px;min-width:280px}.me-search-row{display:flex;gap:4px;align-items:center}.me-search input{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));border-radius:4px;background:var(--md-textarea-bg,#fff);color:var(--md-text,#1f2937);font-size:13px}.me-search input:focus{outline:none;border-color:var(--md-accent,#3b82f6)}.me-search button{padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));background:var(--md-bg,#fff);color:var(--md-text,#1f2937);border-radius:4px;cursor:pointer;font-size:12px;line-height:1}.me-search button:hover{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-count{color:var(--md-muted,#6b7280);font-size:12px;min-width:60px;text-align:center}.me-search .me-search-close{padding:2px 6px}`;
document.head.appendChild(style);
},
@@ -255,28 +333,11 @@ const searchReplacePlugin = {
return;
}
const selected = editor.textarea.value.substring(
editor.textarea.selectionStart,
editor.textarea.selectionEnd
);
const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd);
const panel = document.createElement('div');
panel.className = 'me-search';
panel.dataset.replace = showReplace ? '1' : '0';
panel.innerHTML = `
<div class="me-search-row">
<input type="text" class="me-search-find" placeholder="${(t('searchPlaceholder') || '查找内容')}" value="${escapeAttr(selected)}"/>
<button type="button" class="me-search-prev" title="${(t('findPrev') || '上一个')}">↑</button>
<button type="button" class="me-search-next" title="${(t('findNext') || '下一个')}">↓</button>
<span class="me-search-count"></span>
<button type="button" class="me-search-close" title="${(t('close') || '关闭')}" aria-label="close">×</button>
</div>
<div class="me-search-row me-search-replace-row">
<input type="text" class="me-search-replace" placeholder="${(t('replacePlaceholder') || '替换为')}"/>
<button type="button" class="me-search-replace-one">${(t('replace') || '替换')}</button>
<button type="button" class="me-search-replace-all">${(t('replaceAll') || '全部')}</button>
</div>
`;
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${(t('searchPlaceholder') || '查找内容')}" value="${escapeAttr(selected)}"/><button type="button" class="me-search-prev" title="${(t('findPrev') || '上一个')}">↑</button><button type="button" class="me-search-next" title="${(t('findNext') || '下一个')}">↓</button><span class="me-search-count"></span><button type="button" class="me-search-close" title="${(t('close') || '关闭')}" aria-label="close">×</button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${(t('replacePlaceholder') || '替换为')}"/><button type="button" class="me-search-replace-one">${(t('replace') || '替换')}</button><button type="button" class="me-search-replace-all">${(t('replaceAll') || '全部')}</button></div>`;
editor.el.appendChild(panel);
this._panel = panel;
this._updateReplaceVisible();
@@ -305,22 +366,18 @@ const searchReplacePlugin = {
const q = findInput.value;
editor.textarea.focus();
editor.textarea.setSelectionRange(idx, idx + q.length);
// 滚动到可见
const lineHeight = parseFloat(getComputedStyle(editor.textarea).lineHeight) || 20;
const lineNum = editor.textarea.value.substring(0, idx).split('\n').length - 1;
editor.textarea.scrollTop = Math.max(0, lineNum * lineHeight - editor.textarea.clientHeight / 2);
};
let lastIdxs = [];
let cursor = -1;
const findNext = () => {
lastIdxs = findAll();
if (!lastIdxs.length) return;
const cur = editor.textarea.selectionEnd;
let next = lastIdxs.find((i) => i >= cur);
if (next == null) next = lastIdxs[0];
cursor = lastIdxs.indexOf(next);
selectAt(next);
};
@@ -333,7 +390,6 @@ const searchReplacePlugin = {
if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; }
}
if (prev === -1) prev = lastIdxs[lastIdxs.length - 1];
cursor = lastIdxs.indexOf(prev);
selectAt(prev);
};
@@ -342,10 +398,8 @@ const searchReplacePlugin = {
const r = replaceInput.value;
if (!q) return;
const ta = editor.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const cur = ta.value.substring(start, end);
if (cur === q) {
const start = ta.selectionStart, end = ta.selectionEnd;
if (ta.value.substring(start, end) === q) {
ta.value = ta.value.substring(0, start) + r + ta.value.substring(end);
ta.setSelectionRange(start, start + r.length);
editor._value = ta.value;
@@ -357,8 +411,7 @@ const searchReplacePlugin = {
};
const replaceAll = () => {
const q = findInput.value;
const r = replaceInput.value;
const q = findInput.value, r = replaceInput.value;
if (!q) return;
const ta = editor.textarea;
const before = ta.value;
@@ -373,7 +426,7 @@ const searchReplacePlugin = {
findAll();
};
findInput.addEventListener('input', () => { lastIdxs = findAll(); cursor = -1; });
findInput.addEventListener('input', () => { lastIdxs = findAll(); });
findInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); }
if (e.key === 'Escape') { e.preventDefault(); this._close(editor); }
@@ -391,10 +444,7 @@ const searchReplacePlugin = {
if (selected) findAll();
findInput.focus();
findInput.select();
this._cleanup = () => {
if (panel.parentNode) panel.parentNode.removeChild(panel);
};
this._cleanup = () => { if (panel.parentNode) panel.parentNode.removeChild(panel); };
},
_updateReplaceVisible() {
@@ -421,17 +471,17 @@ const searchReplacePlugin = {
/**
* 图片粘贴插件
* 监听 textarea 粘贴事件,自动将剪贴板中的图片转换为 base64 data URI 并插入。
*/
const imagePastePlugin = {
name: 'imagePaste',
version: '0.1.0',
description: '粘贴图片自动转为 base64 内联',
priority: 60,
_onPaste: null,
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') return;
this._onPaste = (e) => {
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
@@ -460,9 +510,8 @@ const imagePastePlugin = {
},
};
/**
* 预设插件注册表
*/
// ============ 预设插件表 ============
const presetPlugins = {
autoSave: autoSavePlugin,
exportTool: exportToolPlugin,
@@ -470,43 +519,44 @@ const presetPlugins = {
imagePaste: imagePastePlugin,
};
/**
* 转义属性值(用于内联 HTML 属性)
*/
// ============ 实用工具 ============
const escapeAttr = (s) => String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
.replace(/&/g, '&amp;').replace(/"/g, '&quot;')
.replace(/</g, '&lt;').replace(/>/g, '&gt;');
// ============ 插件工具集 ============
/**
* 插件工具
*/
const pluginUtils = {
createManager() { return new PluginManager(); },
manager: defaultPluginManager,
register(name, plugin) { return defaultPluginManager.register(name, plugin); },
unregister(name) { return defaultPluginManager.unregister(name); },
get(name) { return defaultPluginManager.get(name); },
has(name) { return defaultPluginManager.has(name); },
getAll() { return defaultPluginManager.getAll(); },
getNames() { return defaultPluginManager.getNames(); },
enable(name) { return defaultPluginManager.enable(name); },
disable(name) { return defaultPluginManager.disable(name); },
isEnabled(name) { return defaultPluginManager.isEnabled(name); },
getPreset(name) { return presetPlugins[name] ? { ...presetPlugins[name] } : null; },
getAllPresets() { return Object.keys(presetPlugins).reduce((acc, k) => { acc[k] = { ...presetPlugins[k] }; return acc; }, {}); },
unregister(name) { return defaultPluginManager.unregister(name); },
get(name) { return defaultPluginManager.get(name); },
has(name) { return defaultPluginManager.has(name); },
getAll() { return defaultPluginManager.getAll(); },
getNames() { return defaultPluginManager.getNames(); },
enable(name) { return defaultPluginManager.enable(name); },
disable(name) { return defaultPluginManager.disable(name); },
isEnabled(name) { return defaultPluginManager.isEnabled(name); },
getPreset(name) { return presetPlugins[name] ? { ...presetPlugins[name] } : null; },
getAllPresets() {
return Object.keys(presetPlugins).reduce((acc, k) => { acc[k] = { ...presetPlugins[k] }; return acc; }, {});
},
createPlugin(config = {}) {
const result = {
name: config.name || 'custom',
version: config.version || '0.0.0',
description: config.description || '',
// 默认提供空函数,确保 editor.use(plugin) 时 destroy() 调用安全
depends: config.depends || [],
priority: config.priority || 0,
install: () => {},
destroy: () => {},
...config,
};
// 强制类型校验:用户传入非函数时回退为空函数,避免运行时报错
if (typeof result.install !== 'function') result.install = () => {};
if (typeof result.destroy !== 'function') result.destroy = () => {};
return result;
@@ -519,7 +569,11 @@ const pluginUtils = {
if (plugin && plugin.install && typeof plugin.install !== 'function') errors.push('install must be a function');
return { valid: errors.length === 0, errors };
},
// v0.1.6 新增
topologicalSort,
validateConfig,
};
export { presetPlugins, pluginUtils, defaultPluginManager, PluginManager };
export { presetPlugins, pluginUtils, defaultPluginManager, PluginManager, topologicalSort, validateConfig };
export default presetPlugins;