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
+178
View File
@@ -867,3 +867,181 @@ describe('零散分支补全', () => {
ed.destroy();
});
});
// ============ v0.1.6 新增测试 ============
describe('v0.1.6 topologicalSort', () => {
const { topologicalSort } = require('../src/plugins.js');
test('空数组返回空数组', () => {
expect(topologicalSort([])).toEqual([]);
});
test('无依赖保持原序,按 priority 降序', () => {
const plugins = [
{ name: 'a', depends: [], priority: 10 },
{ name: 'b', depends: [], priority: 50 },
{ name: 'c', depends: [], priority: 30 },
];
const sorted = topologicalSort(plugins);
expect(sorted.map((p) => p.name)).toEqual(['b', 'c', 'a']);
});
test('依赖插件排在依赖者之前', () => {
const plugins = [
{ name: 'a', depends: ['c'] },
{ name: 'b', depends: [] },
{ name: 'c', depends: [] },
];
const sorted = topologicalSort(plugins);
const aIdx = sorted.findIndex((p) => p.name === 'a');
const cIdx = sorted.findIndex((p) => p.name === 'c');
expect(cIdx).toBeLessThan(aIdx);
});
test('未知依赖 warn 但不崩溃', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const plugins = [{ name: 'a', depends: ['unknown'] }];
expect(() => topologicalSort(plugins)).not.toThrow();
spy.mockRestore();
});
});
describe('v0.1.6 validateConfig', () => {
const { validateConfig } = require('../src/plugins.js');
test('required 字段缺失报错', () => {
const result = validateConfig({ name: { required: true } }, {});
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
test('default 自动补全', () => {
const result = validateConfig({ delay: { default: 1000 } }, {});
expect(result.valid).toBe(true);
expect(result.patched.delay).toBe(1000);
});
test('type 校验', () => {
const result = validateConfig({ count: { type: 'number' } }, { count: 'abc' });
expect(result.valid).toBe(false);
});
test('enum 校验', () => {
const result = validateConfig({ mode: { enum: ['a', 'b'] } }, { mode: 'c' });
expect(result.valid).toBe(false);
});
});
describe('v0.1.6 editor.unuse()', () => {
test('unuse 卸载已安装插件', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const { MarkdownEditor } = require('../src/core.js');
const ed = new MarkdownEditor(c, {});
let destroyed = false;
ed.use({ name: 'test', install() {}, destroy() { destroyed = true; } });
expect(ed.getPlugins().length).toBe(1);
ed.unuse('test');
expect(destroyed).toBe(true);
expect(ed.getPlugins().length).toBe(0);
ed.destroy();
});
});
describe('v0.1.6 editor shortcuts', () => {
test('registerShortcut 注册快捷键', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const { MarkdownEditor } = require('../src/core.js');
const ed = new MarkdownEditor(c, { value: 'hello' });
let called = false;
ed.registerShortcut('Ctrl+J', () => { called = true; });
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'j', ctrlKey: true, bubbles: true }));
expect(called).toBe(true);
ed.destroy();
});
test('unregisterShortcut 注销快捷键', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const { MarkdownEditor } = require('../src/core.js');
const ed = new MarkdownEditor(c, { value: 'hello' });
let called = false;
ed.registerShortcut('Ctrl+K', () => { called = true; });
ed.unregisterShortcut('Ctrl+K');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }));
// 原来的 Ctrl+K 会触发 link,但 link 需要 promptjsdom 返回 null
// 只验证不崩溃
expect(() => ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }))).not.toThrow();
ed.destroy();
});
test('getShortcuts 返回注册列表', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const { MarkdownEditor } = require('../src/core.js');
const ed = new MarkdownEditor(c, {});
ed.registerShortcut('Ctrl+B', 'bold', 'Bold');
expect(ed.getShortcuts().length).toBe(1);
ed.destroy();
});
});
describe('v0.1.6 editor toast', () => {
test('toast 创建并显示', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const { MarkdownEditor } = require('../src/core.js');
const ed = new MarkdownEditor(c, {});
ed.toast('Test message', { duration: 0 });
const toast = ed.el.querySelector('.me-toast');
expect(toast).not.toBeNull();
expect(toast.textContent).toContain('Test message');
ed.destroy();
});
test('toast 返回 this 支持链式', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const { MarkdownEditor } = require('../src/core.js');
const ed = new MarkdownEditor(c, {});
expect(ed.toast('msg')).toBe(ed);
ed.destroy();
});
});
describe('v0.1.6 editor toolbar config', () => {
test('configureToolbar 重建工具栏', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const { MarkdownEditor } = require('../src/core.js');
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic'] });
const before = ed.toolbarEl.querySelectorAll('.me-btn').length;
ed.configureToolbar(['bold', 'italic', 'h1', 'h2', '|', 'undo']);
const after = ed.toolbarEl.querySelectorAll('.me-btn').length;
expect(after).not.toBe(before);
ed.destroy();
});
test('removeToolbarButton 移除按钮', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const { MarkdownEditor } = require('../src/core.js');
const ed = new MarkdownEditor(c, {});
const before = ed.toolbarEl.querySelectorAll('.me-btn-bold').length;
expect(before).toBe(1);
ed.removeToolbarButton('bold');
const after = ed.toolbarEl.querySelectorAll('.me-btn-bold').length;
expect(after).toBe(0);
ed.destroy();
});
});