feat: v0.4.2 — 审查清偿:8 项修复 + setOutline API + 871 测试
修复:插件面板实例级 i18n / undo-redo 滚动保持 / shortcutHelp 卸载残留 / CSS.escape 环境崩溃 / 大纲光标按源码行号定位 / setLocale 即时刷新 / 浮动工具栏 CJK 宽度 / ko 语言包括号 新增:setOutline(isOutline) 运行时大纲 API、getRenderCacheSize 观测接口 测试:841 → 871(补 7 个事件断言、5 个配置项、修 3 处恒真断言) 工程:CI lint 阻断、build.sh npm ci、rollup 死代码清理、覆盖率阈值门禁
This commit is contained in:
+323
-3
@@ -17,6 +17,11 @@ if (typeof window !== 'undefined' && typeof window.PointerEvent === 'undefined')
|
||||
};
|
||||
}
|
||||
|
||||
// jsdom 未实现 scrollIntoView(大纲点击跳转依赖),提供 no-op stub
|
||||
if (typeof Element !== 'undefined' && typeof (Element.prototype as any).scrollIntoView !== 'function') {
|
||||
(Element.prototype as any).scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
describe('MarkdownEditor - 构造', () => {
|
||||
let container;
|
||||
|
||||
@@ -781,10 +786,32 @@ describe('MarkdownEditor - 同步滚动', () => {
|
||||
document.body.appendChild(c);
|
||||
const longText = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n');
|
||||
const ed = new MarkdownEditor(c, { value: longText, mode: 'split', syncScroll: true });
|
||||
ed.textarea.scrollTop = 100;
|
||||
// jsdom 的 scrollHeight 恒为 0,mock 出可滚动尺寸以驱动比例联动
|
||||
Object.defineProperty(ed.textarea, 'scrollHeight', { configurable: true, value: 2000 });
|
||||
Object.defineProperty(ed.textarea, 'clientHeight', { configurable: true, value: 500 });
|
||||
Object.defineProperty(ed.previewPane, 'scrollHeight', { configurable: true, value: 1000 });
|
||||
Object.defineProperty(ed.previewPane, 'clientHeight', { configurable: true, value: 500 });
|
||||
ed.textarea.scrollTop = 750; // textarea 滚动比例 0.5
|
||||
ed.textarea.dispatchEvent(new Event('scroll'));
|
||||
// preview 应被同步滚动(scrollTop > 0 或为 0,取决于尺寸)
|
||||
expect(typeof ed.previewPane.scrollTop).toBe('number');
|
||||
// preview 按同比例滚动:0.5 * (1000 - 500) = 250
|
||||
expect(ed.previewPane.scrollTop).toBe(250);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('分屏模式下 preview 滚动反向联动 textarea', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const longText = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n');
|
||||
const ed = new MarkdownEditor(c, { value: longText, mode: 'split', syncScroll: true });
|
||||
Object.defineProperty(ed.textarea, 'scrollHeight', { configurable: true, value: 2000 });
|
||||
Object.defineProperty(ed.textarea, 'clientHeight', { configurable: true, value: 500 });
|
||||
Object.defineProperty(ed.previewPane, 'scrollHeight', { configurable: true, value: 1000 });
|
||||
Object.defineProperty(ed.previewPane, 'clientHeight', { configurable: true, value: 500 });
|
||||
ed.previewPane.scrollTop = 250; // preview 滚动比例 0.5
|
||||
ed.previewPane.dispatchEvent(new Event('scroll'));
|
||||
// textarea 按同比例滚动:0.5 * (2000 - 500) = 750
|
||||
expect(ed.textarea.scrollTop).toBe(750);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
@@ -3009,3 +3036,296 @@ describe('MarkdownEditor - v0.2.5 maxLength 默认值', () => {
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.2 事件补齐 ============
|
||||
|
||||
describe('MarkdownEditor - v0.4.2 事件补齐', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
test('渲染路径触发 beforeRender / afterRender 事件', () => {
|
||||
const ed = new MarkdownEditor(container, { value: '# hi', mode: 'split' });
|
||||
const before = jest.fn(); const after = jest.fn();
|
||||
ed.on('beforeRender', before); ed.on('afterRender', after);
|
||||
ed.setValue('# changed');
|
||||
expect(before).toHaveBeenCalledTimes(1);
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('getHTML 触发 beforeRender / afterRender 事件', () => {
|
||||
const ed = new MarkdownEditor(container, { value: '# hi' });
|
||||
const before = jest.fn(); const after = jest.fn();
|
||||
ed.on('beforeRender', before); ed.on('afterRender', after);
|
||||
ed.getHTML();
|
||||
expect(before).toHaveBeenCalledTimes(1);
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('toggleZen 触发 zenChange 事件', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'test' });
|
||||
const onZen = jest.fn();
|
||||
ed.on('zenChange', onZen);
|
||||
ed.toggleZen();
|
||||
expect(onZen).toHaveBeenCalledWith(true, ed);
|
||||
ed.toggleZen();
|
||||
expect(onZen).toHaveBeenLastCalledWith(false, ed);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('toggleFullscreen / exitFullscreen 触发 fullscreen 事件', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'test' });
|
||||
const onFs = jest.fn();
|
||||
ed.on('fullscreen', onFs);
|
||||
ed.toggleFullscreen();
|
||||
expect(ed.isFullscreen()).toBe(true);
|
||||
expect(onFs).toHaveBeenCalledWith(true, ed);
|
||||
ed.exitFullscreen();
|
||||
expect(ed.isFullscreen()).toBe(false);
|
||||
expect(onFs).toHaveBeenLastCalledWith(false, ed);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('全局钩子 beforeCreate / beforeDestroy / afterDestroy 触发', () => {
|
||||
const beforeCreate = jest.fn(); const beforeDestroy = jest.fn(); const afterDestroy = jest.fn();
|
||||
MarkdownEditor.on('beforeCreate', beforeCreate);
|
||||
MarkdownEditor.on('beforeDestroy', beforeDestroy);
|
||||
MarkdownEditor.on('afterDestroy', afterDestroy);
|
||||
const ed = new MarkdownEditor(container, {});
|
||||
expect(beforeCreate).toHaveBeenCalledTimes(1);
|
||||
ed.destroy();
|
||||
expect(beforeDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(afterDestroy).toHaveBeenCalledTimes(1);
|
||||
MarkdownEditor.off('beforeCreate', beforeCreate);
|
||||
MarkdownEditor.off('beforeDestroy', beforeDestroy);
|
||||
MarkdownEditor.off('afterDestroy', afterDestroy);
|
||||
});
|
||||
|
||||
test('copyAsMarkdown 写剪贴板并触发 copy 事件', async () => {
|
||||
const writeText = jest.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: { writeText, write: jest.fn() } });
|
||||
const ed = new MarkdownEditor(container, { value: '# copy test' });
|
||||
const onCopy = jest.fn();
|
||||
ed.on('copy', onCopy);
|
||||
ed.copyAsMarkdown();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(writeText).toHaveBeenCalledWith('# copy test');
|
||||
expect(onCopy).toHaveBeenCalledWith({ type: 'markdown' }, ed);
|
||||
delete (navigator as any).clipboard;
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('copyAsHTML 写富文本剪贴板并触发 copy 事件', async () => {
|
||||
const writeText = jest.fn().mockResolvedValue(undefined);
|
||||
const write = jest.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: { writeText, write } });
|
||||
// jsdom 无 ClipboardItem,注入 stub 供富文本写入
|
||||
const items: any[] = [];
|
||||
(global as any).ClipboardItem = class { constructor(it: any) { items.push(it); } };
|
||||
const ed = new MarkdownEditor(container, { value: '**bold**' });
|
||||
const onCopy = jest.fn();
|
||||
ed.on('copy', onCopy);
|
||||
ed.copyAsHTML();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(write).toHaveBeenCalledTimes(1);
|
||||
expect(write.mock.calls[0][0]).toHaveLength(1);
|
||||
expect(onCopy).toHaveBeenCalledWith({ type: 'html' }, ed);
|
||||
delete (global as any).ClipboardItem;
|
||||
delete (navigator as any).clipboard;
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.2 配置项补齐 ============
|
||||
|
||||
describe('MarkdownEditor - v0.4.2 配置项补齐', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
test('placeholder 配置生效', () => {
|
||||
const ed = new MarkdownEditor(container, { placeholder: '自定义占位' });
|
||||
expect(ed.textarea.placeholder).toBe('自定义占位');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('未配置 placeholder 时使用翻译默认值', () => {
|
||||
const ed = new MarkdownEditor(container, {});
|
||||
expect(ed.textarea.placeholder).toBe(ed.t('placeholder'));
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('height 数字转为 px', () => {
|
||||
const ed = new MarkdownEditor(container, { height: 520 });
|
||||
expect(ed.el.style.height).toBe('520px');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('height 字符串原样使用', () => {
|
||||
const ed = new MarkdownEditor(container, { height: '100%' });
|
||||
expect(ed.el.style.height).toBe('100%');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('spellcheck 默认关闭', () => {
|
||||
const ed = new MarkdownEditor(container, {});
|
||||
expect(ed.textarea.spellcheck).toBe(false);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('spellcheck: true 开启拼写检查', () => {
|
||||
const ed = new MarkdownEditor(container, { spellcheck: true });
|
||||
expect(ed.textarea.spellcheck).toBe(true);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('tabSize 自定义值生效且 Tab 插入对应空格数', () => {
|
||||
const ed = new MarkdownEditor(container, { tabSize: 4 });
|
||||
expect(ed.textarea.style.tabSize).toBe('4');
|
||||
ed.textarea.setSelectionRange(0, 0);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||
expect(ed.getValue()).toBe(' ');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('tabSize: 0 时 Tab 插入制表符', () => {
|
||||
const ed = new MarkdownEditor(container, { tabSize: 0 });
|
||||
ed.textarea.setSelectionRange(0, 0);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||
expect(ed.getValue()).toBe('\t');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('historyDebounce 自定义防抖间隔生效', () => {
|
||||
jest.useFakeTimers();
|
||||
const ed = new MarkdownEditor(container, { historyDebounce: 100 });
|
||||
const before = ed._history.length;
|
||||
ed.textarea.value = 'typed';
|
||||
ed.textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
jest.advanceTimersByTime(50);
|
||||
expect(ed._history.length).toBe(before);
|
||||
jest.advanceTimersByTime(60);
|
||||
expect(ed._history.length).toBe(before + 1);
|
||||
jest.useRealTimers();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.2 undo/redo 滚动保持 ============
|
||||
|
||||
describe('MarkdownEditor - v0.4.2 undo/redo 滚动保持', () => {
|
||||
test('undo/redo 后预览区滚动位置按比例保持', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'v1', mode: 'split' });
|
||||
Object.defineProperty(ed.previewPane, 'scrollHeight', { configurable: true, value: 1000 });
|
||||
Object.defineProperty(ed.previewPane, 'clientHeight', { configurable: true, value: 500 });
|
||||
ed.setValue('v2\n\nmore content');
|
||||
ed.previewPane.scrollTop = 250; // 滚动比例 50%
|
||||
ed.undo();
|
||||
expect(ed.previewPane.scrollTop).toBe(250);
|
||||
ed.redo();
|
||||
expect(ed.previewPane.scrollTop).toBe(250);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.2 大纲光标定位 ============
|
||||
|
||||
describe('MarkdownEditor - v0.4.2 大纲光标定位', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
test('点击大纲按源码行号定位光标(重复标题不串位)', () => {
|
||||
const md = '# alpha\n\ntext\n\n# beta\n\n# alpha\n';
|
||||
const ed = new MarkdownEditor(container, { value: md, outline: true, mode: 'split' });
|
||||
const links = ed.el.querySelectorAll('.me-outline a');
|
||||
expect(links.length).toBe(3);
|
||||
// 第三个链接对应第二个 'alpha' 标题(源码第 7 行)
|
||||
(links[2] as HTMLElement).click();
|
||||
expect(ed.getCursorPosition().line).toBe(7);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('data-line 记录源码行号(1 基)', () => {
|
||||
const ed = new MarkdownEditor(container, { value: '# a\n\n## b\n', outline: true, mode: 'split' });
|
||||
const links = ed.el.querySelectorAll('.me-outline a');
|
||||
expect(links[0].getAttribute('data-line')).toBe('1');
|
||||
expect(links[1].getAttribute('data-line')).toBe('3');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('setOutline 运行时开关大纲面板', () => {
|
||||
const ed = new MarkdownEditor(container, { value: '# title\n\nbody', outline: false, mode: 'split' });
|
||||
expect(ed.isOutline()).toBe(false);
|
||||
expect(ed.el.querySelector('.me-outline')).toBeNull();
|
||||
ed.setOutline(true);
|
||||
expect(ed.isOutline()).toBe(true);
|
||||
expect(ed.el.querySelector('.me-outline')).not.toBeNull();
|
||||
ed.setOutline(false);
|
||||
expect(ed.el.querySelector('.me-outline')).toBeNull();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.2 setLocale 即时刷新 ============
|
||||
|
||||
describe('MarkdownEditor - v0.4.2 setLocale 即时刷新', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
test('setLocale 即时刷新状态栏统计标签', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'hello', locale: 'zh-CN' });
|
||||
expect(ed.statusEl!.textContent).toContain('字符');
|
||||
ed.setLocale('en-US');
|
||||
expect(ed.statusEl!.textContent).toContain('Characters');
|
||||
expect(ed.statusEl!.textContent).not.toContain('字符');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('setLocale 即时刷新大纲标题', () => {
|
||||
const ed = new MarkdownEditor(container, { value: '# t', outline: true, locale: 'zh-CN', mode: 'split' });
|
||||
expect(ed.el.querySelector('.me-outline-title')!.textContent).toBe('大纲');
|
||||
ed.setLocale('en-US');
|
||||
expect(ed.el.querySelector('.me-outline-title')!.textContent).toBe('Outline');
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.2 浮动工具栏中文选区 ============
|
||||
|
||||
describe('MarkdownEditor - v0.4.2 浮动工具栏中文选区', () => {
|
||||
test('中文选区触发浮动工具栏显示且定位计算不抛错', async () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '这是一段中文文本内容' });
|
||||
ed.textarea.setSelectionRange(0, 6);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(ed._floatingToolbar).not.toBeNull();
|
||||
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user