feat: v0.4.3 — 浮动工具栏定位引擎重做 + 程序化编辑统一刷新管线
- 浮动工具栏 mirror 镜像测量:隐藏镜像层复制 textarea 排版样式,选区锚点插零宽字符读像素坐标,CJK/软换行/跨行选区误差全部消除 - 实测尺寸替代魔法数字;滚动 rAF 节流重定位;滚出视口自动隐藏;a11y:role=toolbar + aria-label、visibility 移出 Tab 序列、键盘可达 - 修复死开关/监听器泄漏/销毁后 focus 崩溃/blur 焦点抖动误隐藏 - _afterProgrammaticEdit 统一 8 条程序化路径的渲染/行号/字数/大纲/事件刷新;右键菜单 label 转义、copyAsHTML Firefox 降级、paste 失败提示、Esc 退出全屏 - 测试 871 → 895,e2e 22 → 28 项断言(含真实浏览器浮动工具栏布局验证)
This commit is contained in:
@@ -3329,3 +3329,307 @@ describe('MarkdownEditor - v0.4.2 浮动工具栏中文选区', () => {
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.3 浮动工具栏定位引擎重做 ============
|
||||
|
||||
describe('MarkdownEditor - v0.4.3 浮动工具栏重做', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
/** 触发选区显示浮动工具栏(keyup 路径) */
|
||||
const showToolbar = async (ed: MarkdownEditor, start: number, end: number) => {
|
||||
ed.textarea.setSelectionRange(start, end);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
};
|
||||
|
||||
test('floatingToolbar:false 构造后 toggle 重开可用(死开关修复)', async () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'hello world', floatingToolbar: false });
|
||||
expect(ed._floatingEnabled).toBe(false);
|
||||
ed.toggleFloatingToolbar();
|
||||
expect(ed._floatingEnabled).toBe(true);
|
||||
await showToolbar(ed, 0, 5);
|
||||
expect(ed._floatingToolbar).not.toBeNull();
|
||||
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('选区触发时懒创建镜像层且后续复用不重建', async () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'first line\nsecond line' });
|
||||
expect(ed._floatMirror).toBeNull();
|
||||
await showToolbar(ed, 0, 5);
|
||||
expect(ed._floatMirror).not.toBeNull();
|
||||
expect(ed._floatMirror!.classList.contains('me-float-mirror')).toBe(true);
|
||||
const m1 = ed._floatMirror;
|
||||
await showToolbar(ed, 11, 17);
|
||||
expect(ed._floatMirror).toBe(m1);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('destroy 清理镜像层 DOM', async () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'hello' });
|
||||
await showToolbar(ed, 0, 5);
|
||||
expect(ed._floatMirror).not.toBeNull();
|
||||
ed.destroy();
|
||||
expect(ed._floatMirror).toBeNull();
|
||||
expect(document.querySelector('.me-float-mirror')).toBeNull();
|
||||
});
|
||||
|
||||
test('textarea 滚动触发重定位不抛错(选区仍在视口内保持显示)', async () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'hello world' });
|
||||
await showToolbar(ed, 0, 5);
|
||||
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
|
||||
ed.textarea.dispatchEvent(new Event('scroll', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
// jsdom 无真实布局:验证 rAF 重定位路径执行不抛错、状态未破坏
|
||||
expect(ed._floatingToolbar).not.toBeNull();
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('工具栏 a11y 属性(role / aria-label)', async () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'hello' });
|
||||
await showToolbar(ed, 0, 5);
|
||||
const bar = ed._floatingToolbar!;
|
||||
expect(bar.getAttribute('role')).toBe('toolbar');
|
||||
expect(bar.getAttribute('aria-label')).toBe('格式化选区');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('隐藏态 CSS 使用 visibility(移出 Tab 序列)', () => {
|
||||
new MarkdownEditor(container, { value: 'hello' }); // 触发样式注入
|
||||
const style = document.getElementById('me-float-style')!;
|
||||
expect(style.textContent).toContain('visibility:hidden');
|
||||
expect(style.textContent).toContain('visibility:visible');
|
||||
});
|
||||
|
||||
test('按钮 click 触发 exec 并隐藏工具栏(键盘可达)', async () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'hello world' });
|
||||
await showToolbar(ed, 0, 5);
|
||||
const btn = ed._floatingToolbar!.querySelector('.me-btn') as HTMLElement;
|
||||
btn.click();
|
||||
expect(ed.getValue()).toBe('**hello** world');
|
||||
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(false);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('window resize 后隐藏浮动工具栏', async () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'hello' });
|
||||
await showToolbar(ed, 0, 5);
|
||||
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(false);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('_measureSelectionAnchor 无选区返回 null', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'hello' });
|
||||
ed.textarea.setSelectionRange(0, 0);
|
||||
expect(ed._measureSelectionAnchor()).toBeNull();
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('blur 延迟隐藏带焦点回归校验(焦点抖动防护)', async () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'hello world' });
|
||||
ed.textarea.focus();
|
||||
ed.textarea.setSelectionRange(0, 5);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
|
||||
// 合成 blur(焦点未真实转移):延迟到期时焦点仍在 textarea,不应隐藏
|
||||
ed.textarea.dispatchEvent(new Event('blur'));
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
|
||||
// 真实失焦(activeElement 离开 textarea):延迟到期后隐藏
|
||||
ed.textarea.blur();
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(false);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.3 程序化编辑统一刷新管线 ============
|
||||
|
||||
describe('MarkdownEditor - v0.4.3 程序化编辑统一刷新管线', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
test('Smart Enter 延续列表后预览同步刷新', () => {
|
||||
const ed = new MarkdownEditor(container, { value: '- item', mode: 'split' });
|
||||
expect(ed.previewEl.querySelectorAll('li').length).toBe(1);
|
||||
ed.textarea.setSelectionRange(6, 6);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
expect(ed.getValue()).toBe('- item\n- ');
|
||||
expect(ed.previewEl.querySelectorAll('li').length).toBe(2);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('Smart Enter 后触发 input 事件', () => {
|
||||
const ed = new MarkdownEditor(container, { value: '- item' });
|
||||
const onInput = jest.fn();
|
||||
ed.on('input', onInput);
|
||||
ed.textarea.setSelectionRange(6, 6);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
expect(onInput).toHaveBeenCalledTimes(1);
|
||||
expect(onInput).toHaveBeenCalledWith('- item\n- ', ed);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('括号自动闭合后预览与字数统计刷新', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'abc', mode: 'split' });
|
||||
ed.textarea.setSelectionRange(3, 3);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keypress', { key: '(', bubbles: true, cancelable: true }));
|
||||
expect(ed.getValue()).toBe('abc()');
|
||||
expect(ed.getStats().characters).toBe(5);
|
||||
expect(ed.previewEl.textContent).toBe('abc()');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('Tab 缩进后字数统计刷新', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'ab' });
|
||||
ed.textarea.setSelectionRange(2, 2);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||
expect(ed.getValue()).toBe('ab ');
|
||||
expect(ed.getStats().characters).toBe(4);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('insert 后行号装订线刷新', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'one' });
|
||||
expect(ed.gutter.children.length).toBe(1);
|
||||
ed.insert('\ntwo');
|
||||
expect(ed.gutter.children.length).toBe(2);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('exec h1 后大纲面板延时刷新', async () => {
|
||||
const ed = new MarkdownEditor(container, { value: '标题', outline: true, mode: 'split' });
|
||||
expect(ed.el.querySelectorAll('.me-outline a').length).toBe(0);
|
||||
ed.textarea.setSelectionRange(0, 0);
|
||||
ed.exec('h1');
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
expect(ed.el.querySelectorAll('.me-outline a').length).toBe(1);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('undo 后触发 afterChange 事件', () => {
|
||||
const ed = new MarkdownEditor(container, { value: '' });
|
||||
ed.setValue('v2');
|
||||
ed.setValue('v3');
|
||||
const after = jest.fn();
|
||||
ed.on('afterChange', after);
|
||||
ed.undo();
|
||||
expect(ed.getValue()).toBe('v2');
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('replaceAll 后触发 afterChange 事件', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'abc abc' });
|
||||
const after = jest.fn();
|
||||
ed.on('afterChange', after);
|
||||
ed.replaceAll('abc', 'xyz');
|
||||
expect(ed.getValue()).toBe('xyz xyz');
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('exec bold 触发 beforeChange(oldValue, newValue)', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'hello' });
|
||||
let oldV = ''; let newV = '';
|
||||
ed.on('beforeChange', (o, n) => { oldV = o; newV = n; });
|
||||
ed.textarea.setSelectionRange(0, 5);
|
||||
ed.exec('bold');
|
||||
expect(oldV).toBe('hello');
|
||||
expect(newV).toBe('**hello**');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('onInput 回调在 exec 后触发', () => {
|
||||
const onInput = jest.fn();
|
||||
const ed = new MarkdownEditor(container, { value: 'hello', onInput });
|
||||
ed.textarea.setSelectionRange(0, 5);
|
||||
ed.exec('bold');
|
||||
expect(onInput).toHaveBeenCalledTimes(1);
|
||||
expect(onInput).toHaveBeenCalledWith('**hello**', ed);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.3 周边修复 ============
|
||||
|
||||
describe('MarkdownEditor - v0.4.3 周边修复', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
test('copyAsHTML 无 ClipboardItem 环境降级为纯文本写入', async () => {
|
||||
const writeText = jest.fn().mockResolvedValue(undefined);
|
||||
const write = jest.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: { writeText, write } });
|
||||
delete (global as any).ClipboardItem;
|
||||
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).not.toHaveBeenCalled();
|
||||
expect(writeText).toHaveBeenCalledWith('**bold**');
|
||||
expect(onCopy).toHaveBeenCalledWith({ type: 'html' }, ed);
|
||||
delete (navigator as any).clipboard;
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('右键 paste 被浏览器拒绝时 toast 提示改用 Ctrl+V', () => {
|
||||
// jsdom 未实现 execCommand,直接替换为可控 stub
|
||||
const orig = (document as any).execCommand;
|
||||
(document as any).execCommand = jest.fn().mockReturnValue(false);
|
||||
const ed = new MarkdownEditor(container, { value: 'text' });
|
||||
ed._execContextAction('paste');
|
||||
expect(ed.el.querySelector('.me-toast')).not.toBeNull();
|
||||
expect(ed.el.querySelector('.me-toast')!.textContent).toContain('Ctrl+V');
|
||||
(document as any).execCommand = orig;
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('全屏模式按 Esc 退出', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'text' });
|
||||
ed.toggleFullscreen();
|
||||
expect(ed.isFullscreen()).toBe(true);
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(ed.isFullscreen()).toBe(false);
|
||||
// 非全屏时 Esc 无副作用
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(ed.isFullscreen()).toBe(false);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('右键菜单自定义 label 经 HTML 转义(防注入)', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'text' });
|
||||
ed.registerContextMenu([{ label: '<img src=x onerror=alert(1)>' }]);
|
||||
const e = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
|
||||
ed.el.dispatchEvent(e);
|
||||
// 自定义项追加在默认项(6 个,sep 不算)之后,取最后一个菜单项
|
||||
const items = document.querySelectorAll('.me-context-menu-item');
|
||||
expect(items.length).toBe(7);
|
||||
const span = items[items.length - 1].querySelector('span') as HTMLElement;
|
||||
expect(span).not.toBeNull();
|
||||
expect(span.innerHTML).not.toContain('<img');
|
||||
expect(span.textContent).toBe('<img src=x onerror=alert(1)>');
|
||||
ed._hideContextMenu();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ describe('index.ts - 全局 API', () => {
|
||||
|
||||
test('VERSION 是字符串', () => {
|
||||
expect(typeof VERSION).toBe('string');
|
||||
expect(VERSION).toBe('0.4.2');
|
||||
expect(VERSION).toBe('0.4.3');
|
||||
});
|
||||
|
||||
test('api.default 是 api 本身', () => {
|
||||
|
||||
Reference in New Issue
Block a user