refactor(core): 拆分 4 模块 + 插件状态 Symbol 化 + eslint type-aware
- core.ts 从 1162 行降至 865 行:命令/浮动工具栏/右键菜单/大纲 拆至 commands.ts / floating-toolbar.ts / context-menu.ts / outline.ts (原型安装,API 与测试完全兼容) - 6 个预设插件状态改用 Symbol 键存储,定义 EditorLike 契约接口, 消灭 (editor as any).__xxx 魔法属性 - 实现 maxLength(textarea maxlength + 程序化入口截断)与 zenMode 初始状态(此前为 dead config) - 搜索面板补齐 matchCase/wholeWord(翻译键已有但未实现, 中文全字边界感知) - 分隔条 localStorage key 加入实例 id 隔离 - eslint 启用 type-aware 规则(consistent-type-imports / no-unnecessary-type-assertion),0 errors
This commit is contained in:
@@ -2301,3 +2301,116 @@ describe('MarkdownEditor - v0.2.3 getStatus', () => {
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.2.5 maxLength / zenMode / 分隔条隔离 ============
|
||||
|
||||
describe('MarkdownEditor - v0.2.5 maxLength', () => {
|
||||
test('setValue 超长内容被截断', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { maxLength: 10 });
|
||||
ed.setValue('0123456789ABC');
|
||||
expect(ed.getValue()).toBe('0123456789');
|
||||
expect(ed.textarea.value).toBe('0123456789');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('insert 超长内容被截断', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { maxLength: 5, value: 'ab' });
|
||||
ed.focus();
|
||||
ed.textarea.setSelectionRange(2, 2);
|
||||
ed.insert('CDEFG');
|
||||
expect(ed.getValue()).toBe('abCDE');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('textarea maxlength 属性已绑定', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { maxLength: 42 });
|
||||
expect(ed.textarea.maxLength).toBe(42);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('maxLength 为 0 时不限制', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
expect(ed.textarea.maxLength).toBe(-1);
|
||||
const long = 'x'.repeat(10000);
|
||||
ed.setValue(long);
|
||||
expect(ed.getValue().length).toBe(10000);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.2.5 zenMode 初始状态', () => {
|
||||
test('zenMode: true 启动即进入 Zen 模式', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { zenMode: true });
|
||||
expect(ed.isZen()).toBe(true);
|
||||
expect(ed.el.classList.contains('me-zen')).toBe(true);
|
||||
expect(ed.toolbarEl.style.opacity).toBe('0');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('默认不进入 Zen 模式', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
expect(ed.isZen()).toBe(false);
|
||||
expect(ed.el.classList.contains('me-zen')).toBe(false);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('zenMode 初始开启后 toggleZen 可关闭', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { zenMode: true });
|
||||
ed.toggleZen();
|
||||
expect(ed.isZen()).toBe(false);
|
||||
expect(ed.el.classList.contains('me-zen')).toBe(false);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.2.5 分隔条实例隔离', () => {
|
||||
test('分隔条位置按实例 id 保存', () => {
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { id: 'divider-test-1' });
|
||||
ed.editorPane.style.flex = '0 0 30%';
|
||||
ed._saveDividerPosition();
|
||||
expect(localStorage.getItem('metona-editor-divider-divider-test-1')).toBe('0 0 30%');
|
||||
expect(localStorage.getItem('metona-editor-divider')).toBeNull();
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('不同实例互不覆盖分隔条位置', () => {
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const a = new MarkdownEditor(c, { id: 'dva' });
|
||||
const b = new MarkdownEditor(c, { id: 'dvb' });
|
||||
a.editorPane.style.flex = '0 0 20%';
|
||||
b.editorPane.style.flex = '0 0 70%';
|
||||
a._saveDividerPosition();
|
||||
b._saveDividerPosition();
|
||||
expect(localStorage.getItem('metona-editor-divider-dva')).toBe('0 0 20%');
|
||||
expect(localStorage.getItem('metona-editor-divider-dvb')).toBe('0 0 70%');
|
||||
a.destroy(); b.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
+155
-5
@@ -794,20 +794,21 @@ describe('零散分支补全', () => {
|
||||
});
|
||||
|
||||
test('autoSave save 内部抛错时 warn 不崩溃', () => {
|
||||
jest.useFakeTimers();
|
||||
document.body.innerHTML = '';
|
||||
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'test' });
|
||||
ed.use('autoSave');
|
||||
ed.use('autoSave', { delay: 50 });
|
||||
// 让 editor.getValue 抛错,触发 save 的 catch 分支
|
||||
jest.spyOn(ed, 'getValue').mockImplementation(() => { throw new Error('get failed'); });
|
||||
const cleanup = (ed as any).__autoSaveCleanup;
|
||||
expect(cleanup).toBeDefined();
|
||||
expect(() => cleanup.save()).not.toThrow();
|
||||
ed._emit('change', 'x');
|
||||
expect(() => jest.advanceTimersByTime(60)).not.toThrow();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
ed.destroy();
|
||||
spy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('restoreDraft localStorage.getItem 抛错时返回 null', () => {
|
||||
@@ -833,7 +834,6 @@ describe('零散分支补全', () => {
|
||||
// Ctrl+F 打开面板
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
|
||||
expect(ed.el.querySelector('.me-search')).not.toBeNull();
|
||||
expect((ed as any).__srState._panel).toBeDefined();
|
||||
// Ctrl+Escape 关闭面板(带 Ctrl 才会进入分支)
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', ctrlKey: true, bubbles: true }));
|
||||
expect(ed.el.querySelector('.me-search')).toBeNull();
|
||||
@@ -1125,3 +1125,153 @@ describe('v0.2.0 exportTool 插件', () => {
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.2.5 searchReplace 大小写 / 全字匹配 ============
|
||||
|
||||
describe('v0.2.5 searchReplace 大小写与全字匹配', () => {
|
||||
const setup = (value: string) => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value });
|
||||
ed.use('searchReplace');
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
|
||||
const fi = ed.el.querySelector('.me-search-find') as HTMLInputElement;
|
||||
const ce = ed.el.querySelector('.me-search-count') as HTMLElement;
|
||||
const caseBtn = ed.el.querySelector('.me-search-case') as HTMLButtonElement;
|
||||
const wordBtn = ed.el.querySelector('.me-search-word') as HTMLButtonElement;
|
||||
return { ed, fi, ce, caseBtn, wordBtn };
|
||||
};
|
||||
|
||||
const teardown = (ed: any) => {
|
||||
const panel = ed.el.querySelector('.me-search');
|
||||
if (panel) panel.remove();
|
||||
ed.destroy();
|
||||
};
|
||||
|
||||
test('默认区分大小写', () => {
|
||||
const { ed, fi, ce, caseBtn, wordBtn } = setup('Foo foo FOO');
|
||||
fi.value = 'foo';
|
||||
fi.dispatchEvent(new Event('input'));
|
||||
expect(ce.textContent).toBe('1');
|
||||
expect(caseBtn.classList.contains('me-active')).toBe(true);
|
||||
teardown(ed);
|
||||
});
|
||||
|
||||
test('关闭大小写后匹配全部', () => {
|
||||
const { ed, fi, ce, caseBtn } = setup('Foo foo FOO');
|
||||
fi.value = 'foo';
|
||||
caseBtn.click();
|
||||
fi.dispatchEvent(new Event('input'));
|
||||
expect(caseBtn.classList.contains('me-active')).toBe(false);
|
||||
expect(ce.textContent).toBe('3');
|
||||
teardown(ed);
|
||||
});
|
||||
|
||||
test('全字匹配排除子串', () => {
|
||||
const { ed, fi, ce, wordBtn } = setup('cat category scatter cat');
|
||||
fi.value = 'cat';
|
||||
fi.dispatchEvent(new Event('input'));
|
||||
expect(ce.textContent).toBe('4');
|
||||
wordBtn.click();
|
||||
fi.dispatchEvent(new Event('input'));
|
||||
expect(ce.textContent).toBe('2');
|
||||
teardown(ed);
|
||||
});
|
||||
|
||||
test('全字匹配支持中文边界', () => {
|
||||
const { ed, fi, ce, wordBtn } = setup('测试test测试 test');
|
||||
fi.value = 'test';
|
||||
wordBtn.click();
|
||||
fi.dispatchEvent(new Event('input'));
|
||||
expect(ce.textContent).toBe('1');
|
||||
teardown(ed);
|
||||
});
|
||||
|
||||
test('replaceAll 大小写不敏感替换', () => {
|
||||
const { ed, fi, caseBtn } = setup('Foo foo FOO');
|
||||
fi.value = 'foo';
|
||||
caseBtn.click();
|
||||
const ri = ed.el.querySelector('.me-search-replace') as HTMLInputElement;
|
||||
ri.value = 'bar';
|
||||
const allBtn = ed.el.querySelector('.me-search-replace-all') as HTMLButtonElement;
|
||||
allBtn.click();
|
||||
expect(ed.getValue()).toBe('bar bar bar');
|
||||
teardown(ed);
|
||||
});
|
||||
|
||||
test('正则模式 + 全字匹配', () => {
|
||||
const { ed, fi, ce, wordBtn, caseBtn } = setup('cat category scatter cat');
|
||||
fi.value = 'ca[nt]';
|
||||
const regexBtn = ed.el.querySelector('.me-search-regex') as HTMLButtonElement;
|
||||
regexBtn.click();
|
||||
caseBtn.click();
|
||||
wordBtn.click();
|
||||
fi.dispatchEvent(new Event('input'));
|
||||
expect(ce.textContent).toBe('2');
|
||||
teardown(ed);
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.2.5 exportPDF / imagePaste 尺寸限制 ============
|
||||
|
||||
describe('v0.2.5 exportTool exportPDF', () => {
|
||||
test('exportPDF 创建隐藏 iframe 不抛错', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '# pdf test' });
|
||||
ed.use('exportTool');
|
||||
const printSpy = jest.spyOn(window, 'print').mockImplementation(() => {});
|
||||
expect(() => ed.exportPDF({ title: 'T' })).not.toThrow();
|
||||
printSpy.mockRestore();
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('exportHTML 与 exportPDF 共用构建逻辑', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '# hi' });
|
||||
ed.use('exportTool');
|
||||
expect(typeof (ed as any).exportPDF).toBe('function');
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.2.5 imagePaste 尺寸限制', () => {
|
||||
test('超出 maxSizeKB 时 toast 警告且不插入', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '' });
|
||||
ed.use('imagePaste', { maxSizeKB: 1 });
|
||||
const toastSpy = jest.spyOn(ed, 'toast').mockImplementation(() => ed);
|
||||
const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed);
|
||||
const file = new File([new ArrayBuffer(4096)], 'big.png', { type: 'image/png' });
|
||||
const evt = new Event('paste', { bubbles: true });
|
||||
Object.defineProperty(evt, 'clipboardData', { value: { items: [{ type: 'image/png', getAsFile: () => file }] } });
|
||||
ed.textarea.dispatchEvent(evt as ClipboardEvent);
|
||||
expect(toastSpy).toHaveBeenCalled();
|
||||
expect(insertSpy).not.toHaveBeenCalled();
|
||||
toastSpy.mockRestore();
|
||||
insertSpy.mockRestore();
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('默认 500KB 内正常插入', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '' });
|
||||
ed.use('imagePaste');
|
||||
const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed);
|
||||
const file = new File([new ArrayBuffer(1024)], 'ok.png', { type: 'image/png' });
|
||||
const evt = new Event('paste', { bubbles: true });
|
||||
Object.defineProperty(evt, 'clipboardData', { value: { items: [{ type: 'image/png', getAsFile: () => file }] } });
|
||||
ed.textarea.dispatchEvent(evt as ClipboardEvent);
|
||||
expect(insertSpy).not.toHaveBeenCalled(); // FileReader 异步,同步阶段仅 preventDefault
|
||||
insertSpy.mockRestore();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user