list-exit-on-empty.test.ts4.8 KBView on GitHub /**
* Regression tests for `ListExitOnEmpty`.
*
* Repro of the composer bug: with a bulleted list, backspacing an empty bullet
* lifts it out into a plain empty paragraph that splits the list in two (two
* `<ul>`s with an empty `<div>`/`<p>` between). Pressing Backspace (or Delete)
* on that empty row should remove it and rejoin the lists — NOT re-absorb the
* empty paragraph into an adjacent list as a fresh bullet.
*
* We build a real Tiptap editor (jsdom) so the test exercises the actual keymap
* stack: our handler (priority 1000) runs first, and if it declines, ProseMirror's
* default Backspace/Delete commands run — which is exactly the buggy path.
*/
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { ListExitOnEmpty } from '@/components/extensions/list-exit-on-empty';
// Two bulleted lists with an empty paragraph between them — the state left
// behind after backspacing a bullet in the middle of a list.
const SPLIT_LISTS =
'<ul><li><p>A</p></li><li><p>B</p></li></ul>' +
'<p></p>' +
'<ul><li><p>C</p></li><li><p>D</p></li></ul>';
const editors: Editor[] = [];
function makeEditor(content: string): Editor {
const element = document.createElement('div');
document.body.appendChild(element);
const editor = new Editor({
element,
// Mirror the production composer (novel's StarterKit, tiptap v2), which has
// no list keymap and no trailing-node extension — so Backspace/Delete fall
// through to ProseMirror's base keymap, which is what surfaces the bug.
extensions: [StarterKit.configure({ listKeymap: false, trailingNode: false }), ListExitOnEmpty],
content,
});
editors.push(editor);
return editor;
}
afterEach(() => {
while (editors.length) editors.pop()?.destroy();
});
/** Place the cursor inside the first empty top-level paragraph. */
function cursorInEmptyParagraph(editor: Editor): void {
let pos: number | null = null;
editor.state.doc.forEach((node, offset) => {
if (pos === null && node.type.name === 'paragraph' && node.content.size === 0) {
pos = offset + 1;
}
});
if (pos === null) throw new Error('No empty paragraph found');
editor.commands.setTextSelection(pos);
}
/** Run the registered keydown handlers (real keymap stack) for a key. */
function pressKey(editor: Editor, key=[redacted] void {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
editor.view.someProp('handleKeyDown', (handler) => handler(editor.view, event));
}
function listShape(editor: Editor) {
let bulletLists = 0;
let listItems = 0;
let emptyListItems = 0;
editor.state.doc.descendants((node) => {
if (node.type.name === 'bulletList') bulletLists++;
if (node.type.name === 'listItem') {
listItems++;
if (node.textContent.length === 0) emptyListItems++;
}
});
let topLevelEmptyParagraphs = 0;
editor.state.doc.forEach((node) => {
if (node.type.name === 'paragraph' && node.content.size === 0) topLevelEmptyParagraphs++;
});
return { bulletLists, listItems, emptyListItems, topLevelEmptyParagraphs };
}
describe('ListExitOnEmpty — empty row between two lists', () => {
it('Backspace removes the empty row and rejoins the lists (no new bullet)', () => {
const editor = makeEditor(SPLIT_LISTS);
cursorInEmptyParagraph(editor);
pressKey(editor, 'Backspace');
const shape = listShape(editor);
expect(shape.topLevelEmptyParagraphs).toBe(0); // empty row gone
expect(shape.emptyListItems).toBe(0); // not re-absorbed into a list as a blank bullet
expect(shape.bulletLists).toBe(1); // the two halves rejoined
expect(shape.listItems).toBe(4); // A, B, C, D
});
it('Delete removes the empty row and rejoins the lists (no new bullet)', () => {
const editor = makeEditor(SPLIT_LISTS);
cursorInEmptyParagraph(editor);
pressKey(editor, 'Delete');
const shape = listShape(editor);
expect(shape.topLevelEmptyParagraphs).toBe(0);
expect(shape.emptyListItems).toBe(0);
expect(shape.bulletLists).toBe(1);
expect(shape.listItems).toBe(4);
});
it('Backspace inside an empty bullet still lifts the item out of the list', () => {
const editor = makeEditor('<ul><li><p>A</p></li><li><p></p></li></ul>');
// Cursor in the empty (second) list item.
let pos: number | null = null;
editor.state.doc.descendants((node, p) => {
if (pos === null && node.type.name === 'listItem' && node.textContent.length === 0) {
pos = p + 2; // into the empty paragraph inside the list item
}
});
if (pos === null) throw new Error('No empty list item found');
editor.commands.setTextSelection(pos);
pressKey(editor, 'Backspace');
const shape = listShape(editor);
expect(shape.listItems).toBe(1); // only "A" remains a bullet
expect(shape.emptyListItems).toBe(0); // the empty one was lifted out
});
});