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();
});
});
+9 -6
View File
@@ -29,7 +29,7 @@ describe('index.ts - 全局 API', () => {
test('VERSION 是字符串', () => {
expect(typeof VERSION).toBe('string');
expect(VERSION).toBe('0.4.1');
expect(VERSION).toBe('0.4.2');
});
test('api.default 是 api 本身', () => {
@@ -193,14 +193,17 @@ describe('index.ts - 解析器导出', () => {
});
test('registerBlockHandler 注册自定义块', () => {
let matched = false;
registerBlockHandler({
name: 'testBlock',
name: 'testBlockIndex',
priority: 50,
test: () => null,
parse: (_l, i) => ({ token: null, newIndex: i }),
test: (line) => { if (line === '!!!custom!!!') { matched = true; return true; } return null; },
parse: (_l, i) => ({ token: { type: 'testBlockIndex', content: 'hit' }, newIndex: i + 1 }),
});
// 不抛错即通过
expect(true).toBe(true);
const html = parseMarkdown('!!!custom!!!\nafter');
// 自定义 handler 被真实命中并产出 token(回退渲染为 me-block-* div
expect(matched).toBe(true);
expect(html).toContain('me-block-testBlockIndex');
});
});
+9 -3
View File
@@ -3,7 +3,7 @@
* 覆盖 Markdown 解析器的所有语法分支与安全特性
*/
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler } from '../src/parser';
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, getRenderCacheSize, registerBlockHandler } from '../src/parser';
describe('parseMarkdown - 基础', () => {
test('空输入返回空字符串', () => {
@@ -909,12 +909,18 @@ describe('parseMarkdown - v0.1.15 registerBlockHandler', () => {
describe('parseMarkdown - 覆盖率:渲染缓存淘汰', () => {
test('缓存超过上限触发 FIFO 淘汰', () => {
// MAX_CACHE_SIZE = 300,填充 301 个极短文本触发淘汰(避免 CI 超时)
clearRenderCache();
const short = 'x';
for (let i = 0; i < 301; i++) {
parseMarkdown(short + i);
}
// 不应抛错,且至少有一次淘汰发生
expect(true).toBe(true);
// 缓存条目数被 FIFO 上限约束在 300,证明淘汰真实发生
expect(getRenderCacheSize()).toBe(300);
// 超限后再写入仍维持上限
parseMarkdown('overflow-entry');
expect(getRenderCacheSize()).toBe(300);
clearRenderCache();
expect(getRenderCacheSize()).toBe(0);
});
});
+112 -4
View File
@@ -570,7 +570,9 @@ describe('searchReplace 插件 - 完整流程', () => {
input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true }));
ed.el.querySelector('.me-search-next').click();
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
// 首个匹配位于 0,且选中内容即搜索词
expect(ed.textarea.selectionStart).toBe(0);
expect(ed.getSelectedText()).toBe('foo');
});
test('查找上一个高亮匹配', () => {
@@ -580,7 +582,9 @@ describe('searchReplace 插件 - 完整流程', () => {
input.dispatchEvent(new Event('input', { bubbles: true }));
ed.el.querySelector('.me-search-next').click();
ed.el.querySelector('.me-search-prev').click();
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
// next 选中第一处(0),prev 回绕到最后一处('foo bar foo baz foo' 中 16
expect(ed.textarea.selectionStart).toBe(16);
expect(ed.getSelectedText()).toBe('foo');
});
test('空查询不抛错', () => {
@@ -600,7 +604,8 @@ describe('searchReplace 插件 - 完整流程', () => {
input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
expect(ed.textarea.selectionStart).toBe(0);
expect(ed.getSelectedText()).toBe('foo');
});
test('Shift+Enter 在 findInput 上查找上一个', () => {
@@ -609,7 +614,9 @@ describe('searchReplace 插件 - 完整流程', () => {
input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true }));
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
// 初始光标在 0,prev 无更早匹配时回绕到最后一处(16)
expect(ed.textarea.selectionStart).toBe(16);
expect(ed.getSelectedText()).toBe('foo');
});
test('替换行 display 切换(none ↔ flex', () => {
@@ -1301,3 +1308,104 @@ describe('v0.2.5 imagePaste 尺寸限制', () => {
ed.destroy();
});
});
// ============ v0.4.2 fileSystem 事件补齐 ============
describe('fileSystem 插件 - fileOpened / fileSaved 事件', () => {
afterEach(() => {
delete (window as any).showOpenFilePicker;
delete (window as any).showSaveFilePicker;
});
test('openFile 触发 fileOpened 事件并载入内容', async () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' });
const onOpened = jest.fn();
ed.on('fileOpened', onOpened);
const fakeFile = { name: 'doc.md', text: async () => '# opened content' };
const handle = { getFile: async () => fakeFile };
(window as any).showOpenFilePicker = async () => [handle];
ed.use('fileSystem');
const result = await (ed as any).openFile();
expect(result).toEqual({ name: 'doc.md', content: '# opened content', handle });
expect(ed.getValue()).toBe('# opened content');
expect(onOpened).toHaveBeenCalledWith({ name: 'doc.md', handle }, ed);
ed.destroy();
});
test('saveFile 触发 fileSaved 事件', async () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# save me' });
const onSaved = jest.fn();
ed.on('fileSaved', onSaved);
const writable = { write: jest.fn().mockResolvedValue(undefined), close: jest.fn().mockResolvedValue(undefined) };
const handle = { createWritable: async () => writable };
(window as any).showOpenFilePicker = async () => [];
(window as any).showSaveFilePicker = async () => handle;
ed.use('fileSystem');
const ok = await (ed as any).saveFile();
expect(ok).toBe(true);
expect(writable.write).toHaveBeenCalledWith('# save me');
expect(onSaved).toHaveBeenCalledWith({ handle }, ed);
ed.destroy();
});
});
// ============ v0.4.2 插件面板实例级 i18n ============
describe('v0.4.2 插件面板实例级 i18n', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('searchReplace 面板文案跟随实例 localeen-US', () => {
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'en-US' });
ed.use('searchReplace');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true, cancelable: true }));
const input = ed.el.querySelector('.me-search-find') as HTMLInputElement;
expect(input.placeholder).toBe('Find');
const replaceBtn = ed.el.querySelector('.me-search-replace-all');
expect(replaceBtn.textContent).toBe('Replace All');
ed.el.querySelector('.me-search-close').click();
ed.destroy();
});
test('searchReplace 面板文案跟随实例 localezh-CN', () => {
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'zh-CN' });
ed.use('searchReplace');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true, cancelable: true }));
const input = ed.el.querySelector('.me-search-find') as HTMLInputElement;
expect(input.placeholder).toBe('查找内容');
ed.el.querySelector('.me-search-close').click();
ed.destroy();
});
test('shortcutHelp 面板文案跟随实例 localeen-US', () => {
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'en-US' });
ed.use('shortcutHelp');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true }));
const overlay = document.querySelector('.me-shortcut-overlay') as HTMLElement;
expect(overlay).not.toBeNull();
expect(overlay.querySelector('h3')!.textContent).toContain('Shortcuts');
expect(overlay.textContent).toContain('Bold');
ed.destroy();
});
test('unuse shortcutHelp 移除已打开的面板(不残留 DOM)', () => {
const ed = new MarkdownEditor(container, { value: 'foo' });
ed.use('shortcutHelp');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true }));
expect(document.querySelector('.me-shortcut-overlay')).not.toBeNull();
ed.unuse('shortcutHelp');
expect(document.querySelector('.me-shortcut-overlay')).toBeNull();
ed.destroy();
});
});