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:
2026-08-19 22:30:52 +08:00
parent 5966ac7500
commit 5f4915222e
22 changed files with 704 additions and 107 deletions
+323 -3
View File
@@ -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 恒为 0mock 出可滚动尺寸以驱动比例联动
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();
});
});