@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
296 lines (250 loc) • 10.1 kB
text/typescript
import { TextSelection } from "@tiptap/pm/state";
import { afterEach, describe, expect, it } from "vite-plus/test";
import { BlockNoteEditor } from "./BlockNoteEditor.js";
/**
* @vitest-environment jsdom
*/
const editorsToCleanup: BlockNoteEditor<any, any, any>[] = [];
afterEach(() => {
for (const editor of editorsToCleanup) {
editor.unmount();
}
editorsToCleanup.length = 0;
});
function mountEditor(editor: BlockNoteEditor<any, any, any>) {
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(`<p>Pasted</p>`);
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(`<p>Hello <strong>world</strong></p>`);
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(`<p>First</p><p>Second</p>`);
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(`<h1>Heading</h1>`);
// 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(`<h1>Heading</h1><p>Body</p>`);
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(`<ul><li>Pasted item</li></ul>`);
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(`<p>hello</p>`);
const blocks = editor.document;
expect(blocks[0].type).toBe("bulletListItem");
expect(blocks[0].content).toEqual([
{ type: "text", text: "abchello", styles: {} },
]);
});
it("pastes bare <li>a</li><li>b</li> into an empty list item as two list items", () => {
const editor = BlockNoteEditor.create({
initialContent: [{ type: "bulletListItem", content: "" }],
});
mountEditor(editor);
selectStartOfFirstBlock(editor);
editor.pasteHTML(`<li>a</li><li>b</li>`);
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 <li>a</li><li>b</li> 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(`<li>a</li><li>b</li>`);
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(`<ul><li>a</li><li>b</li></ul>`);
// 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(`<ul><li>a</li><li>b</li></ul>`);
// 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(`<ul><li>Outer<ul><li>Inner</li></ul></li></ul>`);
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(`<p>added</p>`);
const blocks = editor.document;
expect(blocks[0].type).toBe("paragraph");
expect(blocks[0].content).toEqual([
{ type: "text", text: "Existing added", styles: {} },
]);
});
});