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:
@@ -410,3 +410,78 @@ describe('createI18nManager', () => {
|
||||
expect(m.t('bold')).toBe('Bold');
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.1.6 新增测试 ============
|
||||
|
||||
describe('v0.1.6 实例级 i18n', () => {
|
||||
const { createInstanceI18n } = require('../src/i18n.js');
|
||||
|
||||
test('createInstanceI18n 创建独立上下文', () => {
|
||||
const mockEditor = {
|
||||
config: { locale: 'en-US' },
|
||||
el: document.createElement('div'),
|
||||
textarea: document.createElement('textarea'),
|
||||
toolbarEl: document.createElement('div'),
|
||||
_emit: jest.fn(),
|
||||
};
|
||||
document.body.appendChild(mockEditor.el);
|
||||
const ctx = createInstanceI18n(mockEditor);
|
||||
expect(ctx.get()).toBe('en-US');
|
||||
expect(typeof ctx.set).toBe('function');
|
||||
expect(typeof ctx.t).toBe('function');
|
||||
document.body.removeChild(mockEditor.el);
|
||||
});
|
||||
|
||||
test('实例 setLocale 触发 localeChange 事件', () => {
|
||||
const mockEditor = {
|
||||
config: { locale: 'zh-CN' },
|
||||
el: document.createElement('div'),
|
||||
textarea: document.createElement('textarea'),
|
||||
toolbarEl: document.createElement('div'),
|
||||
_emit: jest.fn(),
|
||||
};
|
||||
document.body.appendChild(mockEditor.el);
|
||||
const ctx = createInstanceI18n(mockEditor);
|
||||
ctx.set('en-US');
|
||||
expect(mockEditor._emit).toHaveBeenCalledWith('localeChange', expect.objectContaining({ locale: 'en-US' }));
|
||||
document.body.removeChild(mockEditor.el);
|
||||
});
|
||||
|
||||
test('实例 t 函数正确翻译', () => {
|
||||
const mockEditor = {
|
||||
config: { locale: 'zh-CN' },
|
||||
el: document.createElement('div'),
|
||||
textarea: document.createElement('textarea'),
|
||||
toolbarEl: document.createElement('div'),
|
||||
_emit: jest.fn(),
|
||||
};
|
||||
document.body.appendChild(mockEditor.el);
|
||||
const ctx = createInstanceI18n(mockEditor);
|
||||
expect(ctx.t('bold')).toBe('粗体');
|
||||
document.body.removeChild(mockEditor.el);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.6 复数规则', () => {
|
||||
const { addTranslations, t, setCurrentLocale } = require('../src/i18n.js');
|
||||
|
||||
beforeEach(() => {
|
||||
setCurrentLocale('en-US');
|
||||
});
|
||||
|
||||
test('单数 one 形式', () => {
|
||||
addTranslations('en-US', { items: { one: '{count} item', other: '{count} items' } });
|
||||
expect(t('items', { count: 1 })).toBe('1 item');
|
||||
});
|
||||
|
||||
test('复数 other 形式', () => {
|
||||
addTranslations('en-US', { items: { one: '{count} item', other: '{count} items' } });
|
||||
expect(t('items', { count: 5 })).toBe('5 items');
|
||||
});
|
||||
|
||||
test('中文 always other', () => {
|
||||
addTranslations('zh-CN', { items: { other: '{count} 个项目' } });
|
||||
setCurrentLocale('zh-CN');
|
||||
expect(t('items', { count: 3 })).toBe('3 个项目');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 需要 prompt,jsdom 返回 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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user