import { TextSelection } from "@tiptap/pm/state"; import { afterEach, describe, expect, it } from "vitest"; import { BlockNoteEditor } from "./BlockNoteEditor.js"; /** * @vitest-environment jsdom */ const editorsToCleanup: BlockNoteEditor[] = []; afterEach(() => { for (const editor of editorsToCleanup) { editor.unmount(); } editorsToCleanup.length = 0; }); function mountEditor(editor: BlockNoteEditor) { editorsToCleanup.push(editor); editor.mount(document.createElement("div")); } function selectStartOfFirstBlock(editor: BlockNoteEditor) { editor.transact((tr) => { let pos: number | undefined; tr.doc.descendants((node, nodePos) => { if (node.type.spec.group === "blockContent") { pos = nodePos + 1; return false; } return pos === undefined; }); tr.setSelection(TextSelection.create(tr.doc, pos!)); }); } describe("paste into empty inline-content block", () => { it.each(["bulletListItem", "numberedListItem", "checkListItem"] as const)( "pastes a paragraph into an empty %s without replacing it", (type) => { const editor = BlockNoteEditor.create({ initialContent: [{ type, content: "" }], }); mountEditor(editor); selectStartOfFirstBlock(editor); editor.pasteHTML(`

Pasted

`); const blocks = editor.document; expect(blocks[0].type).toBe(type); expect(blocks[0].content).toEqual([ { type: "text", text: "Pasted", styles: {} }, ]); }, ); it("inserts paragraph content into an empty list item without dropping marks", () => { const editor = BlockNoteEditor.create({ initialContent: [{ type: "bulletListItem", content: "" }], }); mountEditor(editor); selectStartOfFirstBlock(editor); editor.pasteHTML(`

Hello world

`); const blocks = editor.document; expect(blocks[0].type).toBe("bulletListItem"); expect(blocks[0].content).toEqual([ { type: "text", text: "Hello ", styles: {} }, { type: "text", text: "world", styles: { bold: true } }, ]); }); it("merges leading paragraph into empty list item and inserts rest as siblings", () => { const editor = BlockNoteEditor.create({ initialContent: [{ type: "bulletListItem", content: "" }], }); mountEditor(editor); selectStartOfFirstBlock(editor); editor.pasteHTML(`

First

Second

`); const blocks = editor.document; expect(blocks[0].type).toBe("bulletListItem"); expect(blocks[0].content).toEqual([ { type: "text", text: "First", styles: {} }, ]); expect(blocks[1].type).toBe("paragraph"); expect(blocks[1].content).toEqual([ { type: "text", text: "Second", styles: {} }, ]); }); it("replaces an empty list item with a heading when pasting a heading", () => { const editor = BlockNoteEditor.create({ initialContent: [{ type: "bulletListItem", content: "" }], }); mountEditor(editor); selectStartOfFirstBlock(editor); editor.pasteHTML(`

Heading

`); // The empty list item is replaced by the heading rather than absorbing // its inline content. Headings carry semantic meaning the user explicitly // chose, so we keep them as-is. const blocks = editor.document; expect(blocks[0].type).toBe("heading"); expect(blocks[0].content).toEqual([ { type: "text", text: "Heading", styles: {} }, ]); }); it("keeps the list item but discards the heading wrapper when a paragraph follows the heading", () => { const editor = BlockNoteEditor.create({ initialContent: [{ type: "bulletListItem", content: "" }], }); mountEditor(editor); selectStartOfFirstBlock(editor); // Heading first means the leading block is not a paragraph, so the // unwrap/retype rule doesn't apply: the empty list item gets replaced // by the pasted heading and the trailing paragraph follows as a sibling. editor.pasteHTML(`

Heading

Body

`); const blocks = editor.document; expect(blocks[0].type).toBe("heading"); expect(blocks[0].content).toEqual([ { type: "text", text: "Heading", styles: {} }, ]); expect(blocks[1].type).toBe("paragraph"); expect(blocks[1].content).toEqual([ { type: "text", text: "Body", styles: {} }, ]); }); it("still replaces the empty list item when pasting another list item", () => { const editor = BlockNoteEditor.create({ initialContent: [{ type: "bulletListItem", content: "" }], }); mountEditor(editor); selectStartOfFirstBlock(editor); editor.pasteHTML(``); const blocks = editor.document; expect(blocks[0].type).toBe("bulletListItem"); // The empty list item should be replaced (not have inline content // appended in-place), which matches the existing behavior. expect(blocks[0].content).toEqual([ { type: "text", text: "Pasted item", styles: {} }, ]); }); it("pastes a paragraph into a non-empty list item without replacing it", () => { const editor = BlockNoteEditor.create({ initialContent: [{ type: "bulletListItem", content: "abc" }], }); mountEditor(editor); editor.setTextCursorPosition(editor.document[0].id, "end"); editor.pasteHTML(`

hello

`); const blocks = editor.document; expect(blocks[0].type).toBe("bulletListItem"); expect(blocks[0].content).toEqual([ { type: "text", text: "abchello", styles: {} }, ]); }); it("pastes bare
  • a
  • b
  • into an empty list item as two list items", () => { const editor = BlockNoteEditor.create({ initialContent: [{ type: "bulletListItem", content: "" }], }); mountEditor(editor); selectStartOfFirstBlock(editor); editor.pasteHTML(`
  • a
  • b
  • `); const blocks = editor.document; expect(blocks[0].type).toBe("bulletListItem"); expect(blocks[0].content).toEqual([ { type: "text", text: "a", styles: {} }, ]); expect(blocks[1].type).toBe("bulletListItem"); expect(blocks[1].content).toEqual([ { type: "text", text: "b", styles: {} }, ]); }); it("pastes bare
  • a
  • b
  • into a non-empty list item, splicing the first into the cursor", () => { const editor = BlockNoteEditor.create({ initialContent: [{ type: "bulletListItem", content: "X" }], }); mountEditor(editor); editor.setTextCursorPosition(editor.document[0].id, "end"); editor.pasteHTML(`
  • a
  • b
  • `); const blocks = editor.document; expect(blocks[0].type).toBe("bulletListItem"); expect(blocks[0].content).toEqual([ { type: "text", text: "Xa", styles: {} }, ]); expect(blocks[1].type).toBe("bulletListItem"); expect(blocks[1].content).toEqual([ { type: "text", text: "b", styles: {} }, ]); }); it("pastes two list items into an empty list item as two siblings", () => { const editor = BlockNoteEditor.create({ initialContent: [{ type: "bulletListItem", content: "" }], }); mountEditor(editor); selectStartOfFirstBlock(editor); editor.pasteHTML(``); // The empty list item is replaced by the two pasted list items. const blocks = editor.document; expect(blocks[0].type).toBe("bulletListItem"); expect(blocks[0].content).toEqual([ { type: "text", text: "a", styles: {} }, ]); expect(blocks[1].type).toBe("bulletListItem"); expect(blocks[1].content).toEqual([ { type: "text", text: "b", styles: {} }, ]); }); it("pastes two list items into a non-empty list item, splicing the first into the cursor", () => { const editor = BlockNoteEditor.create({ initialContent: [{ type: "bulletListItem", content: "X" }], }); mountEditor(editor); editor.setTextCursorPosition(editor.document[0].id, "end"); editor.pasteHTML(``); // The first list item's inline content is spliced at the cursor (the // existing list item becomes "Xa") and the second list item becomes a // new sibling. const blocks = editor.document; expect(blocks[0].type).toBe("bulletListItem"); expect(blocks[0].content).toEqual([ { type: "text", text: "Xa", styles: {} }, ]); expect(blocks[1].type).toBe("bulletListItem"); expect(blocks[1].content).toEqual([ { type: "text", text: "b", styles: {} }, ]); }); it("preserves nested list structure when pasting a nested list into an empty list item", () => { const editor = BlockNoteEditor.create({ initialContent: [{ type: "bulletListItem", content: "" }], }); mountEditor(editor); selectStartOfFirstBlock(editor); // Pasting a list with nested children: the leading block is a list item, // so the unwrap/retype rule does not apply and the existing slice-level // list-nesting fix in `transformPasted` keeps the nested structure // intact. editor.pasteHTML(``); const blocks = editor.document; expect(blocks[0].type).toBe("bulletListItem"); expect(blocks[0].content).toEqual([ { type: "text", text: "Outer", styles: {} }, ]); expect(blocks[0].children).toHaveLength(1); expect(blocks[0].children[0].type).toBe("bulletListItem"); expect(blocks[0].children[0].content).toEqual([ { type: "text", text: "Inner", styles: {} }, ]); }); it("preserves the existing paragraph behavior when pasting into a non-empty paragraph", () => { const editor = BlockNoteEditor.create({ initialContent: [{ type: "paragraph", content: "Existing " }], }); mountEditor(editor); editor.setTextCursorPosition(editor.document[0].id, "end"); editor.pasteHTML(`

    added

    `); const blocks = editor.document; expect(blocks[0].type).toBe("paragraph"); expect(blocks[0].content).toEqual([ { type: "text", text: "Existing added", styles: {} }, ]); }); });