Reimplement as a tokenizer with GFM parity

This commit is contained in:
gsb
2026-04-30 07:09:19 +00:00
parent 005db2f431
commit d41716c8b2
28 changed files with 4798 additions and 1398 deletions
+87 -70
View File
@@ -1,13 +1,13 @@
import { ribbit, resetDOM } from './setup';
const r = ribbit();
const lib = ribbit();
describe('ToolbarManager', () => {
beforeEach(() => resetDOM('**bold** text'));
describe('button registration', () => {
it('registers tag buttons', () => {
const editor = new r.Editor({});
const editor = new lib.Editor({});
editor.run();
expect(editor.toolbar.buttons.get('bold')).toBeDefined();
expect(editor.toolbar.buttons.get('italic')).toBeDefined();
@@ -15,7 +15,7 @@ describe('ToolbarManager', () => {
});
it('registers editor actions', () => {
const editor = new r.Editor({});
const editor = new lib.Editor({});
editor.run();
expect(editor.toolbar.buttons.get('save')).toBeDefined();
expect(editor.toolbar.buttons.get('toggle')).toBeDefined();
@@ -23,23 +23,30 @@ describe('ToolbarManager', () => {
});
it('registers macro buttons', () => {
const editor = new r.Editor({
macros: [{ name: 'user', toHTML: () => 'u' }],
const editor = new lib.Editor({
macros: [{
name: 'user',
toHTML: () => 'u',
}],
});
editor.run();
expect(editor.toolbar.buttons.get('macro:user')).toBeDefined();
});
it('skips macros with button: false', () => {
const editor = new r.Editor({
macros: [{ name: 'hidden', toHTML: () => '', button: false }],
const editor = new lib.Editor({
macros: [{
name: 'hidden',
toHTML: () => '',
button: false,
}],
});
editor.run();
expect(editor.toolbar.buttons.get('macro:hidden')).toBeUndefined();
});
it('skips tags without button', () => {
const editor = new r.Editor({});
const editor = new lib.Editor({});
editor.run();
expect(editor.toolbar.buttons.get('paragraph')).toBeUndefined();
});
@@ -47,7 +54,7 @@ describe('ToolbarManager', () => {
describe('button properties', () => {
it('bold has correct label and shortcut', () => {
const editor = new r.Editor({});
const editor = new lib.Editor({});
editor.run();
const bold = editor.toolbar.buttons.get('bold')!;
expect(bold.label).toBe('Bold');
@@ -55,19 +62,19 @@ describe('ToolbarManager', () => {
});
it('bold action is wrap', () => {
const editor = new r.Editor({});
const editor = new lib.Editor({});
editor.run();
expect(editor.toolbar.buttons.get('bold')!.action).toBe('wrap');
});
it('save action is custom', () => {
const editor = new r.Editor({});
const editor = new lib.Editor({});
editor.run();
expect(editor.toolbar.buttons.get('save')!.action).toBe('custom');
});
it('table has template', () => {
const editor = new r.Editor({});
const editor = new lib.Editor({});
editor.run();
const table = editor.toolbar.buttons.get('table')!;
expect(table.template).toContain('Header');
@@ -75,8 +82,11 @@ describe('ToolbarManager', () => {
});
it('macro button has insert action', () => {
const editor = new r.Editor({
macros: [{ name: 'toc', toHTML: () => '' }],
const editor = new lib.Editor({
macros: [{
name: 'toc',
toHTML: () => '',
}],
});
editor.run();
const btn = editor.toolbar.buttons.get('macro:toc')!;
@@ -87,7 +97,7 @@ describe('ToolbarManager', () => {
describe('button.hide() and button.show()', () => {
it('hide sets visible false', () => {
const editor = new r.Editor({});
const editor = new lib.Editor({});
editor.run();
const bold = editor.toolbar.buttons.get('bold')!;
expect(bold.visible).toBe(true);
@@ -96,7 +106,7 @@ describe('ToolbarManager', () => {
});
it('show restores visible', () => {
const editor = new r.Editor({});
const editor = new lib.Editor({});
editor.run();
const bold = editor.toolbar.buttons.get('bold')!;
bold.hide();
@@ -107,121 +117,124 @@ describe('ToolbarManager', () => {
describe('render()', () => {
it('returns an HTMLElement', () => {
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const el = editor.toolbar.render();
expect(el.tagName).toBe('NAV');
expect(el.className).toBe('ribbit-toolbar');
const toolbar = editor.toolbar.render();
expect(toolbar.tagName).toBe('NAV');
expect(toolbar.className).toBe('ribbit-toolbar');
});
it('contains buttons', () => {
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const el = editor.toolbar.render();
expect(el.querySelector('.ribbit-btn-bold')).not.toBeNull();
expect(el.querySelector('.ribbit-btn-save')).not.toBeNull();
const toolbar = editor.toolbar.render();
expect(toolbar.querySelector('.ribbit-btn-bold')).not.toBeNull();
expect(toolbar.querySelector('.ribbit-btn-save')).not.toBeNull();
});
it('buttons have aria-label', () => {
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const el = editor.toolbar.render();
const bold = el.querySelector('.ribbit-btn-bold');
const toolbar = editor.toolbar.render();
const bold = toolbar.querySelector('.ribbit-btn-bold');
expect(bold?.getAttribute('aria-label')).toBe('Bold');
});
it('buttons have title with shortcut', () => {
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const el = editor.toolbar.render();
const bold = el.querySelector('.ribbit-btn-bold');
const toolbar = editor.toolbar.render();
const bold = toolbar.querySelector('.ribbit-btn-bold');
expect(bold?.getAttribute('title')).toBe('Bold (Ctrl+B)');
});
it('renders spacers', () => {
const editor = new r.Editor({
const editor = new lib.Editor({
autoToolbar: false,
toolbar: ['bold', '', 'save'],
});
editor.run();
const el = editor.toolbar.render();
expect(el.querySelector('.spacer')).not.toBeNull();
const toolbar = editor.toolbar.render();
expect(toolbar.querySelector('.spacer')).not.toBeNull();
});
it('renders dropdown groups', () => {
const editor = new r.Editor({
const editor = new lib.Editor({
autoToolbar: false,
toolbar: [{ group: 'Test', items: ['bold', 'italic'] }],
toolbar: [{
group: 'Test',
items: ['bold', 'italic'],
}],
});
editor.run();
const el = editor.toolbar.render();
expect(el.querySelector('.ribbit-dropdown')).not.toBeNull();
const toolbar = editor.toolbar.render();
expect(toolbar.querySelector('.ribbit-dropdown')).not.toBeNull();
});
});
describe('auto-render', () => {
it('inserts toolbar before editor by default', () => {
resetDOM();
const editor = new r.Editor({});
const editor = new lib.Editor({});
editor.run();
const toolbar = editor.element.previousElementSibling;
expect(toolbar?.className).toBe('ribbit-toolbar');
const toolbarElement = editor.element.previousElementSibling;
expect(toolbarElement?.className).toBe('ribbit-toolbar');
});
it('does not insert when autoToolbar is false', () => {
resetDOM();
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbar = editor.element.previousElementSibling;
expect(toolbar?.className || '').not.toBe('ribbit-toolbar');
const toolbarElement = editor.element.previousElementSibling;
expect(toolbarElement?.className || '').not.toBe('ribbit-toolbar');
});
});
describe('custom layout', () => {
it('respects custom toolbar order', () => {
const editor = new r.Editor({
const editor = new lib.Editor({
autoToolbar: false,
toolbar: ['save', 'bold'],
});
editor.run();
const el = editor.toolbar.render();
const buttons = el.querySelectorAll('button');
const toolbar = editor.toolbar.render();
const buttons = toolbar.querySelectorAll('button');
expect(buttons[0]?.className).toBe('ribbit-btn-save');
expect(buttons[1]?.className).toBe('ribbit-btn-bold');
});
it('auto-generates layout when not specified', () => {
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const el = editor.toolbar.render();
expect(el.querySelectorAll('button').length).toBeGreaterThan(3);
const toolbar = editor.toolbar.render();
expect(toolbar.querySelectorAll('button').length).toBeGreaterThan(3);
});
});
describe('enable/disable', () => {
it('disable adds disabled class', () => {
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const el = editor.toolbar.render();
const toolbar = editor.toolbar.render();
editor.toolbar.disable();
const bold = el.querySelector('.ribbit-btn-bold');
const bold = toolbar.querySelector('.ribbit-btn-bold');
expect(bold?.classList.contains('disabled')).toBe(true);
});
it('enable removes disabled class', () => {
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const el = editor.toolbar.render();
const toolbar = editor.toolbar.render();
editor.toolbar.disable();
editor.toolbar.enable();
const bold = el.querySelector('.ribbit-btn-bold');
const bold = toolbar.querySelector('.ribbit-btn-bold');
expect(bold?.classList.contains('disabled')).toBe(false);
});
});
describe('updateActiveState', () => {
it('sets active class on matching buttons', () => {
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
editor.toolbar.render();
editor.toolbar.updateActiveState(['bold']);
@@ -230,7 +243,7 @@ describe('ToolbarManager', () => {
});
it('clears active when not in list', () => {
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
editor.toolbar.render();
editor.toolbar.updateActiveState(['bold']);
@@ -241,19 +254,19 @@ describe('ToolbarManager', () => {
describe('heading and list buttons', () => {
it('registers h1-h6', () => {
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
for (let i = 1; i <= 6; i++) {
const btn = editor.toolbar.buttons.get(`h${i}`);
for (let level = 1; level <= 6; level++) {
const btn = editor.toolbar.buttons.get(`h${level}`);
expect(btn).toBeDefined();
expect(btn!.label).toBe(`H${i}`);
expect(btn!.shortcut).toBe(`Ctrl+${i}`);
expect(btn!.label).toBe(`H${level}`);
expect(btn!.shortcut).toBe(`Ctrl+${level}`);
expect(btn!.action).toBe('prefix');
}
});
it('registers ul and ol', () => {
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
expect(editor.toolbar.buttons.get('ul')!.shortcut).toBe('Ctrl+Shift+8');
expect(editor.toolbar.buttons.get('ol')!.shortcut).toBe('Ctrl+Shift+7');
@@ -262,7 +275,7 @@ describe('ToolbarManager', () => {
describe('keyboard shortcuts', () => {
it('all formatting buttons have shortcuts', () => {
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const expected = ['bold', 'italic', 'code', 'link', 'save'];
for (const id of expected) {
@@ -271,7 +284,7 @@ describe('ToolbarManager', () => {
});
it('block buttons have shortcuts', () => {
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
expect(editor.toolbar.buttons.get('fencedCode')!.shortcut).toBe('Ctrl+Shift+E');
expect(editor.toolbar.buttons.get('blockquote')!.shortcut).toBe('Ctrl+Shift+.');
@@ -280,7 +293,7 @@ describe('ToolbarManager', () => {
});
it('editor actions have shortcuts', () => {
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
expect(editor.toolbar.buttons.get('toggle')!.shortcut).toBe('Ctrl+Shift+V');
expect(editor.toolbar.buttons.get('markdown')!.shortcut).toBe('Ctrl+/');
@@ -291,9 +304,13 @@ describe('ToolbarManager', () => {
it('triggers editor.save()', () => {
resetDOM();
let saved = false;
const editor = new r.Editor({
const editor = new lib.Editor({
autoToolbar: false,
on: { save: () => { saved = true; } },
on: {
save: () => {
saved = true;
},
},
});
editor.run();
editor.toolbar.render();
@@ -305,7 +322,7 @@ describe('ToolbarManager', () => {
describe('toggle button', () => {
it('switches from view to wysiwyg', () => {
resetDOM();
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
editor.toolbar.render();
expect(editor.getState()).toBe('view');
@@ -315,7 +332,7 @@ describe('ToolbarManager', () => {
it('switches from wysiwyg to view', () => {
resetDOM();
const editor = new r.Editor({ autoToolbar: false });
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
editor.wysiwyg();
editor.toolbar.render();