feat: v0.2.1 — reference links, context menu, RTL, ja/ko, regex search, hooks, copy API
CI / test (16.x) (push) Canceled after 0s
CI / test (18.x) (push) Canceled after 0s
CI / test (20.x) (push) Canceled after 0s

## Added
- Reference link/image resolution: [text][ref] + ![alt][ref] with [ref]: url definitions
- Right-click context menu: undo/redo/cut/copy/paste/selectAll + custom items
- RTL CSS layout support for Arabic, Hebrew, Persian etc.
- Japanese (ja) and Korean (ko) locales with 60+ keys each
- Divider position localStorage persistence
- Regex search toggle in search/replace panel
- Export HTML with embedded CSS styles
- beforeChange / afterChange lifecycle hooks (instance + global)
- copyAsMarkdown() / copyAsHTML() clipboard APIs
- CHANGELOG.md, CONTRIBUTING.md, CI workflow (.github/workflows/ci.yml)
- 2 new test suites: index.test.ts, styles.test.ts (684 total tests, +74)

## Changed
- autoSave plugin: closure-based state per instance instead of this context
- Plugin install() now receives options as second argument
- RTL locale detection: now uses language prefix (ar-SA → RTL)
- Rollup dev mode: only builds UMD format
- prepublishOnly now includes typecheck + test
- Version bumped to 0.2.1

## Fixed
- [text][ref] now correctly renders as link (was raw text)
- ![alt][ref] no longer produces empty src
- autoSave plugin state isolation across multiple editor instances
- Footnote definitions no longer consumed by refDef handler
This commit is contained in:
2026-07-25 08:53:58 +08:00
parent 16464af0ae
commit 1cd5b63174
22 changed files with 1448 additions and 116 deletions
+217 -1
View File
@@ -536,7 +536,7 @@ describe('MarkdownEditor - 构造边界', () => {
destroy: jest.fn(),
};
const ed = new MarkdownEditor(document.createElement('div'), { plugins: [plugin] });
expect(plugin.install).toHaveBeenCalledWith(ed);
expect(plugin.install).toHaveBeenCalledWith(ed, {});
ed.destroy();
expect(plugin.destroy).toHaveBeenCalledWith(ed);
});
@@ -1731,3 +1731,219 @@ describe('MarkdownEditor - v0.2.0 getStatus plugins', () => {
ed.destroy();
});
});
// ============ v0.2.1 覆盖率补齐测试 ============
describe('MarkdownEditor - v0.2.1 工具栏键盘导航', () => {
test('ArrowRight 聚焦下一个按钮', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic', 'code'] });
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
const first = btns[0] as HTMLButtonElement;
first.focus();
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
// 焦点应移动
expect(document.activeElement).not.toBe(first);
ed.destroy();
});
test('ArrowLeft 聚焦上一个按钮', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic'] });
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
(btns[1] as HTMLButtonElement).focus();
(btns[1] as HTMLButtonElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
expect(document.activeElement).toBe(btns[0]);
ed.destroy();
});
test('Home 聚焦第一个按钮', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic', 'code'] });
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
(btns[2] as HTMLButtonElement).focus();
(btns[2] as HTMLButtonElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
expect(document.activeElement).toBe(btns[0]);
ed.destroy();
});
test('End 聚焦最后一个按钮', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic', 'code'] });
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
(btns[0] as HTMLButtonElement).focus();
(btns[0] as HTMLButtonElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
expect(document.activeElement).toBe(btns[btns.length - 1]);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 ARIA live', () => {
test('_initAriaLive 创建 sr-only 元素', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
const live = ed.el.querySelector('.me-sr-only');
expect(live).not.toBeNull();
expect(live!.getAttribute('aria-live')).toBe('polite');
ed.destroy();
});
test('_announce 设置消息', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed._announce('test message');
const live = ed.el.querySelector('.me-sr-only') as HTMLElement;
expect(live).not.toBeNull();
// jsdom 中 requestAnimationFrame 可能不被支持,但不应抛错
expect(() => ed._announce('hello')).not.toThrow();
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 当前行高亮', () => {
test('_updateCurrentLine 高亮当前行', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'line1\nline2\nline3', lineNumbers: true });
ed.textarea.setSelectionRange(7, 7); // 第二行
ed._updateCurrentLine();
const active = ed.gutter.querySelector('.me-gutter-active');
expect(active).not.toBeNull();
ed.destroy();
});
test('_updateCurrentLine 无 gutter 时不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'test', lineNumbers: false });
expect(() => ed._updateCurrentLine()).not.toThrow();
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 右键上下文菜单', () => {
test('contextmenu 事件显示菜单', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello' });
ed.el.dispatchEvent(new MouseEvent('contextmenu', { clientX: 100, clientY: 100, bubbles: true }));
const menu = document.querySelector('.me-context-menu');
expect(menu).not.toBeNull();
// 清理
if (menu) menu.remove();
ed.destroy();
});
test('上下文菜单点击外部关闭', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello' });
ed.el.dispatchEvent(new MouseEvent('contextmenu', { clientX: 100, clientY: 100, bubbles: true }));
expect(document.querySelector('.me-context-menu')).not.toBeNull();
document.body.click();
// once listener removes it
expect(document.querySelector('.me-context-menu')).toBeNull();
ed.destroy();
});
test('registerContextMenu 添加自定义菜单项', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.registerContextMenu([{ label: 'Custom Action', onClick: jest.fn() }]);
expect(ed._contextMenuItems.length).toBe(1);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 copy API', () => {
test('copyAsMarkdown 不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# hello' });
expect(() => ed.copyAsMarkdown()).not.toThrow();
ed.destroy();
});
test('copyAsHTML 不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# hello' });
expect(() => ed.copyAsHTML()).not.toThrow();
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 beforeChange/afterChange 钩子', () => {
test('setValue 触发 beforeChange', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'old' });
const handler = jest.fn();
ed.on('beforeChange', handler);
ed.setValue('new');
expect(handler).toHaveBeenCalledWith('old', 'new', ed);
ed.destroy();
});
test('setValue 触发 afterChange', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'old' });
const handler = jest.fn();
ed.on('afterChange', handler);
ed.setValue('new');
expect(handler).toHaveBeenCalledWith('new', ed);
ed.destroy();
});
test('全局 beforeChange/afterChange 钩子', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const beforeFn = jest.fn();
const afterFn = jest.fn();
MarkdownEditor.on('beforeChange', beforeFn);
MarkdownEditor.on('afterChange', afterFn);
const ed = new MarkdownEditor(c, { value: 'test' });
ed.setValue('updated');
expect(beforeFn).toHaveBeenCalled();
expect(afterFn).toHaveBeenCalled();
MarkdownEditor.off('beforeChange', beforeFn);
MarkdownEditor.off('afterChange', afterFn);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 分隔条持久化', () => {
test('_saveDividerPosition / _restoreDividerPosition 不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { mode: 'split' });
expect(() => ed._saveDividerPosition()).not.toThrow();
expect(() => ed._restoreDividerPosition()).not.toThrow();
ed.destroy();
});
});