feat: v0.4.0 — 安全修复×6 + 增量性能×3 + Playwright E2E + 实例级 i18n
CI / test-parser (push) Successful in 9m27s
CI / test-core (push) Successful in 9m34s
CI / test-rest (push) Successful in 9m27s
CI / e2e (push) Failing after 5m15s
CI / verify (20.x) (push) Successful in 9m54s
CI / verify (18.x) (push) Successful in 9m56s
CI / verify (24.x) (push) Successful in 9m48s
CI / test-parser (push) Successful in 9m27s
CI / test-core (push) Successful in 9m34s
CI / test-rest (push) Successful in 9m27s
CI / e2e (push) Failing after 5m15s
CI / verify (20.x) (push) Successful in 9m54s
CI / verify (18.x) (push) Successful in 9m56s
CI / verify (24.x) (push) Successful in 9m48s
0.3.0 修复版: - 脚注 id 属性注入 XSS 防护(行内引用 + 脚注区) - 全局插件注入移至 afterCreate(searchReplace 等依赖 textarea 的插件真正生效)+ use() 同名防重 - 行内渲染缓存附加 refs 指纹,防跨文档引用链接串数据 - 白名单裸标签 <u>/</u> 透传,underline(Ctrl+U)预览可见 - getStatus()/渲染 env 使用实例 locale;replaceAll 替换文本按字面处理 - replaceAllRegex 保留 $1 捕获组语义 0.3.1 性能版: - 高亮规则按语言缓存(registerLanguage 覆盖失效),bench +33% - 统计增量计算:字数/词数/行数差异区间,击键零全量扫描 - outline 树构建 O(n²) → 迭代栈 O(n) - 拖放图片 >500KB 拦截、scrollToLine 真实行高、undo/redo 光标恢复 - selectionChange/cursorMove 与浮动工具栏解耦、unregisterShortcut 大小写归一 - toast 接入 7 种 ANIMATIONS、实例主题订阅随 destroy 断开(dispose) 0.4.0 工程版: - Playwright 真实浏览器冒烟测试(e2e/,22 项断言)+ CI e2e job - sideEffects: false 便于 tree-shaking - MeEditor.destroy() 全面复位(全局钩子清理 + 内部注入钩子重建) - 实例 locale 统一作用于全部 UI 文案(状态栏/工具栏/右键菜单/大纲) 测试 782 → 826,全绿;typecheck/lint/build 通过
This commit is contained in:
@@ -461,6 +461,29 @@ describe('MarkdownEditor - 插件', () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('同名插件重复 use 只安装一次', () => {
|
||||
let installs = 0;
|
||||
const p = { name: 'dup-plugin', install: () => { installs++; } };
|
||||
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
ed.use(p);
|
||||
ed.use(p);
|
||||
ed.use(p);
|
||||
expect(installs).toBe(1);
|
||||
expect(ed.getPlugins().length).toBe(1);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('unuse 后同名插件可重新安装', () => {
|
||||
let installs = 0;
|
||||
const p = { name: 're-install', install: () => { installs++; }, destroy() {} };
|
||||
ed.use(p);
|
||||
ed.unuse('re-install');
|
||||
ed.use(p);
|
||||
expect(installs).toBe(2);
|
||||
expect(ed.getPlugins().length).toBe(1);
|
||||
});
|
||||
|
||||
test('addToolbarButton 追加按钮', () => {
|
||||
ed.addToolbarButton({ action: 'custom', title: 'Custom' });
|
||||
const btn = ed.toolbarEl.querySelector('.me-btn-custom-custom');
|
||||
@@ -1705,6 +1728,44 @@ describe('MarkdownEditor - v0.2.0 outline 构建', () => {
|
||||
}
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('outline 树结构完整(嵌套 ul 正确闭合)', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {
|
||||
value: '# A\n## A1\n### A1a\n# B\n## B1\n### B1a\n#### B1a1\n## B2',
|
||||
outline: true, mode: 'split',
|
||||
});
|
||||
ed._buildOutline();
|
||||
const panel = ed.el.querySelector('.me-outline');
|
||||
const html = panel.innerHTML;
|
||||
const ulOpen = (html.match(/<ul>/g) || []).length;
|
||||
const ulClose = (html.match(/<\/ul>/g) || []).length;
|
||||
expect(ulOpen).toBe(ulClose);
|
||||
const liOpen = (html.match(/<li /g) || []).length;
|
||||
const liClose = (html.match(/<\/li>/g) || []).length;
|
||||
expect(liOpen).toBe(liClose);
|
||||
expect(liOpen).toBe(8);
|
||||
expect(panel.querySelectorAll('a').length).toBe(8);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('outline 长文档线性构建不抛错', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const lines = [];
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const lvl = (i % 4) + 1;
|
||||
lines.push('#'.repeat(lvl) + ' Heading ' + i);
|
||||
}
|
||||
const ed = new MarkdownEditor(c, { value: lines.join('\n'), outline: true, mode: 'split' });
|
||||
ed._buildOutline();
|
||||
const panel = ed.el.querySelector('.me-outline');
|
||||
expect(panel.querySelectorAll('a').length).toBe(200);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.2.0 gutter 更新', () => {
|
||||
@@ -2098,6 +2159,260 @@ describe('MarkdownEditor - v0.2.3 光标/选区 API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.3.1 增量统计 ============
|
||||
|
||||
describe('MarkdownEditor - v0.3.1 增量统计', () => {
|
||||
let ed;
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
ed = new MarkdownEditor(c, { value: '' });
|
||||
});
|
||||
afterEach(() => ed.destroy());
|
||||
|
||||
const fullStats = (v: string) => {
|
||||
const cnChars = (v.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||
const enWords = (v.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
|
||||
return { characters: v.length, chineseChars: cnChars, englishWords: enWords, words: cnChars + enWords, lines: v ? v.split('\n').length : 0 };
|
||||
};
|
||||
|
||||
test('增量结果与全量一致(逐步输入)', () => {
|
||||
const inputs = ['hello world', 'hello world foo', 'hello world foobar', '你好世界 hello', '你好世界\nhello\n世界', '中文 x 混合 test\n第二行', ''];
|
||||
for (const v of inputs) {
|
||||
ed.setValue(v, { silent: true });
|
||||
const stats = ed.getStats();
|
||||
const expectStats = fullStats(v);
|
||||
expect(stats.characters).toBe(expectStats.characters);
|
||||
expect(stats.chineseChars).toBe(expectStats.chineseChars);
|
||||
expect(stats.englishWords).toBe(expectStats.englishWords);
|
||||
expect(stats.words).toBe(expectStats.words);
|
||||
expect(stats.lines).toBe(expectStats.lines);
|
||||
}
|
||||
});
|
||||
|
||||
test('单词中间删除字符后统计正确', () => {
|
||||
ed.setValue('hello world', { silent: true });
|
||||
expect(ed.getStats().englishWords).toBe(2);
|
||||
ed.setValue('helloworld', { silent: true });
|
||||
expect(ed.getStats().englishWords).toBe(1);
|
||||
ed.setValue('hello', { silent: true });
|
||||
expect(ed.getStats().englishWords).toBe(1);
|
||||
});
|
||||
|
||||
test('追加单词与删除行后统计正确', () => {
|
||||
ed.setValue('hello world', { silent: true });
|
||||
ed.setValue('hello world extra', { silent: true });
|
||||
expect(ed.getStats().englishWords).toBe(3);
|
||||
ed.setValue('hello world extra\nnew line', { silent: true });
|
||||
expect(ed.getStats().lines).toBe(2);
|
||||
ed.setValue('single', { silent: true });
|
||||
expect(ed.getStats().lines).toBe(1);
|
||||
});
|
||||
|
||||
test('连续追加 50 次后与全量一致', () => {
|
||||
ed.setValue('初始文本 with words', { silent: true });
|
||||
for (let i = 0; i < 50; i++) {
|
||||
ed.setValue(ed.getValue() + ' 追加' + i + 'x', { silent: true });
|
||||
}
|
||||
const v = ed.getValue();
|
||||
const stats = ed.getStats();
|
||||
const expectStats = fullStats(v);
|
||||
expect(stats.chineseChars).toBe(expectStats.chineseChars);
|
||||
expect(stats.englishWords).toBe(expectStats.englishWords);
|
||||
expect(stats.lines).toBe(expectStats.lines);
|
||||
});
|
||||
|
||||
test('gutter 行数跟随统计缓存', () => {
|
||||
ed.setValue('a\nb\nc', { silent: true });
|
||||
expect(ed.gutter.children.length).toBe(3);
|
||||
ed.setValue('a\nb', { silent: true });
|
||||
expect(ed.gutter.children.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.3.1 P2 体验一致性 ============
|
||||
|
||||
describe('MarkdownEditor - v0.3.1 undo 光标恢复', () => {
|
||||
test('undo 后光标位置被 clamp 恢复', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '' });
|
||||
ed.setValue('hello world');
|
||||
ed.textarea.setSelectionRange(11, 11);
|
||||
ed.setValue('hello world foo');
|
||||
ed.textarea.setSelectionRange(15, 15);
|
||||
ed.undo();
|
||||
expect(ed.getValue()).toBe('hello world');
|
||||
expect(ed.textarea.selectionStart).toBe(11);
|
||||
expect(ed.textarea.selectionEnd).toBe(11);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('redo 后光标不越界', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '' });
|
||||
ed.setValue('ab');
|
||||
ed.undo();
|
||||
ed.redo();
|
||||
expect(ed.getValue()).toBe('ab');
|
||||
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.3.1 selectionChange 与浮动工具栏解耦', () => {
|
||||
test('floatingToolbar: false 时 selectionChange 仍触发', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'hello world', floatingToolbar: false });
|
||||
const handler = jest.fn();
|
||||
ed.on('selectionChange', handler);
|
||||
ed.textarea.setSelectionRange(0, 5);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(handler.mock.calls[0][0]).toHaveProperty('start', 0);
|
||||
expect(handler.mock.calls[0][0]).toHaveProperty('end', 5);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('floatingToolbar: false 时 cursorMove 仍触发', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'ab', floatingToolbar: false });
|
||||
const handler = jest.fn();
|
||||
ed.on('cursorMove', handler);
|
||||
ed.textarea.setSelectionRange(1, 1);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(handler).toHaveBeenCalled();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.3.1 unregisterShortcut 大小写归一', () => {
|
||||
test('注册 Ctrl+B 后可用 ctrl+b 注销', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
ed.registerShortcut('Ctrl+B', 'bold');
|
||||
expect(ed.getShortcuts().length).toBe(1);
|
||||
ed.unregisterShortcut('ctrl+b');
|
||||
expect(ed.getShortcuts().length).toBe(0);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.3.1 toast 动画接入', () => {
|
||||
test('所有内置动画类型不抛错', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
['fade', 'slide', 'scale', 'bounce', 'flip', 'rotate', 'zoom'].forEach((a) => {
|
||||
ed.toast('msg', { animation: a, duration: 0 });
|
||||
const t = ed.el.querySelector('.me-toast');
|
||||
expect(t).not.toBeNull();
|
||||
expect(t.className).toContain(`me-toast-${a}`);
|
||||
t.remove();
|
||||
});
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('toast 应用动画进入样式', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
ed.toast('hello', { animation: 'slide', duration: 0 });
|
||||
const t = ed.el.querySelector('.me-toast') as HTMLElement;
|
||||
expect(t).not.toBeNull();
|
||||
expect(t.style.transition).toContain('400ms');
|
||||
expect(t.style.opacity).toBe('0');
|
||||
t.remove();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.3.1 拖放图片大小限制', () => {
|
||||
test('超大图片拖入时 toast 警告且不插入', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '' });
|
||||
const toastSpy = jest.spyOn(ed, 'toast').mockImplementation(() => ed);
|
||||
const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed);
|
||||
const file = new File([new ArrayBuffer(1024 * 1024)], 'big.png', { type: 'image/png' });
|
||||
const dt = { files: [file] };
|
||||
const evt = new Event('drop', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(evt, 'dataTransfer', { value: dt });
|
||||
ed.textarea.dispatchEvent(evt as DragEvent);
|
||||
expect(toastSpy).toHaveBeenCalled();
|
||||
expect(insertSpy).not.toHaveBeenCalled();
|
||||
toastSpy.mockRestore();
|
||||
insertSpy.mockRestore();
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('小图片拖入正常插入', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '' });
|
||||
const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed);
|
||||
const file = new File([new ArrayBuffer(1024)], 'ok.png', { type: 'image/png' });
|
||||
const dt = { files: [file] };
|
||||
const evt = new Event('drop', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(evt, 'dataTransfer', { value: dt });
|
||||
ed.textarea.dispatchEvent(evt as DragEvent);
|
||||
expect(insertSpy).not.toHaveBeenCalled(); // FileReader 异步,同步阶段仅校验
|
||||
insertSpy.mockRestore();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.4.0 实例 locale 决定 UI 文案', () => {
|
||||
test('实例 locale 为 en-US 时状态栏与按钮为英文', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'hello', locale: 'en-US', wordCount: true });
|
||||
expect(ed.statusEl.textContent).toContain('Characters');
|
||||
const boldBtn = ed.toolbarEl.querySelector('.me-btn[data-action="bold"]') as HTMLElement;
|
||||
expect(boldBtn.title).toBe('Bold');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('实例 locale 为 zh-CN 时状态栏与按钮为中文(不受浏览器默认影响)', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '你好', locale: 'zh-CN', wordCount: true });
|
||||
expect(ed.statusEl.textContent).toContain('字符');
|
||||
const boldBtn = ed.toolbarEl.querySelector('.me-btn[data-action="bold"]') as HTMLElement;
|
||||
expect(boldBtn.title).toBe('粗体');
|
||||
expect(ed.textarea.getAttribute('aria-label')).toBe('编辑');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('右键菜单标签跟随实例 locale', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'text', locale: 'en-US' });
|
||||
ed.el.dispatchEvent(new MouseEvent('contextmenu', { clientX: 10, clientY: 10, bubbles: true }));
|
||||
const menu = document.querySelector('.me-context-menu');
|
||||
expect(menu.textContent).toContain('Undo');
|
||||
if (menu) menu.remove();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.2.3 内容操作 API ============
|
||||
|
||||
describe('MarkdownEditor - v0.2.3 内容操作 API', () => {
|
||||
@@ -2151,6 +2466,19 @@ describe('MarkdownEditor - v0.2.3 内容操作 API', () => {
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('replaceAll 替换文本中的 $ 按字面处理', () => {
|
||||
ed.setValue('a b a');
|
||||
const count = ed.replaceAll('a', '$1');
|
||||
expect(count).toBe(2);
|
||||
expect(ed.getValue()).toBe('$1 b $1');
|
||||
});
|
||||
|
||||
test('replaceAllRegex 保留正则捕获组语义', () => {
|
||||
ed.setValue('hello 2024');
|
||||
ed.replaceAllRegex(/(\d{4})/, '$1!');
|
||||
expect(ed.getValue()).toBe('hello 2024!');
|
||||
});
|
||||
|
||||
test('lineCount 返回行数', () => {
|
||||
expect(ed.lineCount()).toBe(1);
|
||||
ed.setValue('a\nb\nc');
|
||||
@@ -2300,6 +2628,17 @@ describe('MarkdownEditor - v0.2.3 getStatus', () => {
|
||||
expect(Array.isArray(s.plugins)).toBe(true);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('getStatus locale 跟随实例语言', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { locale: 'en-US' });
|
||||
ed.setLocale('de');
|
||||
expect(ed.getStatus().locale).toBe('de');
|
||||
expect(ed.getLocale()).toBe('de');
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.2.5 maxLength / zenMode / 分隔条隔离 ============
|
||||
|
||||
Reference in New Issue
Block a user