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:
2026-08-20 20:43:01 +08:00
parent ac6638247f
commit d6a618af5d
16 changed files with 646 additions and 115 deletions
+304
View File
@@ -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();
});
});