@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
662 lines (589 loc) • 17.8 kB
text/typescript
import { describe, expect, it } from "vite-plus/test";
import { afterAll, beforeAll } from "vite-plus/test";
import { PartialBlock } from "../../../../blocks/defaultBlocks.js";
import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
/**
* Custom test setup with a document designed to reproduce nesting/unnesting bugs.
*
* BLO-835 / BLO-899: liftListItem produces invalid content when a nested block
* has siblings after it in the same blockGroup.
* BLO-953: Backspace at start of indented block with multi-level children
* causes deeply nested content to be lost.
* BLO-844 / BLO-847: Deleting parent block then operating on children causes
* RangeError.
*/
function setupNestTestEnv() {
let editor: BlockNoteEditor;
const div = document.createElement("div");
beforeAll(() => {
editor = BlockNoteEditor.create();
editor.mount(div);
});
afterAll(() => {
editor._tiptapEditor.destroy();
editor = undefined as any;
});
return (doc: PartialBlock[]) => {
editor.replaceBlocks(editor.document, doc);
return editor;
};
}
const withEditor = setupNestTestEnv();
describe("unnestBlock / liftListItem", () => {
// BLO-835: liftListItem error with siblings after nested children
// Structure:
// block1
// block2 ← unnest this
// block3
// block4
// block5
//
// Expected: block2 lifts out, block5 becomes child of block2
describe("BLO-835: unnest block with siblings after and nested children", () => {
it("should not throw when unnesting a block that has siblings after it", () => {
const editor = withEditor([
{
id: "block1",
type: "paragraph",
content: "Block 1",
children: [
{
id: "block2",
type: "paragraph",
content: "Block 2",
children: [
{
id: "block3",
type: "paragraph",
content: "Block 3",
},
{
id: "block4",
type: "paragraph",
content: "Block 4",
},
],
},
{
id: "block5",
type: "paragraph",
content: "Block 5",
},
],
},
]);
editor.setTextCursorPosition("block2", "start");
expect(() => {
editor.unnestBlock();
}).not.toThrow();
expect(editor.document).toMatchSnapshot();
});
it("should move siblings after into lifted block's children", () => {
const editor = withEditor([
{
id: "block1",
type: "paragraph",
content: "Block 1",
children: [
{
id: "block2",
type: "paragraph",
content: "Block 2",
},
{
id: "block5",
type: "paragraph",
content: "Block 5",
},
],
},
]);
editor.setTextCursorPosition("block2", "start");
expect(() => {
editor.unnestBlock();
}).not.toThrow();
// block2 should now be at root level after block1
// block5 should be a child of block2
expect(editor.document).toMatchSnapshot();
});
it("should handle unnesting the first of many siblings", () => {
const editor = withEditor([
{
id: "block1",
type: "paragraph",
content: "Block 1",
children: [
{
id: "block2",
type: "paragraph",
content: "Block 2",
},
{
id: "block3",
type: "paragraph",
content: "Block 3",
},
{
id: "block4",
type: "paragraph",
content: "Block 4",
},
],
},
]);
editor.setTextCursorPosition("block2", "start");
expect(() => {
editor.unnestBlock();
}).not.toThrow();
// block2 at root, block3 and block4 become children of block2
expect(editor.document).toMatchSnapshot();
});
});
// BLO-899: Shift-Tab on second-level nested child (not last) causes error
// Structure:
// parent
// child1
// grandchild1 ← unnest this
// grandchild2
// child2
// grandchild3
// grandchild4
//
describe("BLO-899: Shift-Tab on second-level nested block", () => {
it("should not throw when unnesting a deeply nested block with siblings", () => {
const editor = withEditor([
{
id: "parent",
type: "paragraph",
content: "Parent",
children: [
{
id: "child1",
type: "paragraph",
content: "Child 1",
children: [
{
id: "grandchild1",
type: "paragraph",
content: "Grandchild 1",
},
{
id: "grandchild2",
type: "paragraph",
content: "Grandchild 2",
},
],
},
{
id: "child2",
type: "paragraph",
content: "Child 2",
children: [
{
id: "grandchild3",
type: "paragraph",
content: "Grandchild 3",
},
{
id: "grandchild4",
type: "paragraph",
content: "Grandchild 4",
},
],
},
],
},
]);
editor.setTextCursorPosition("grandchild1", "start");
expect(() => {
editor.unnestBlock();
}).not.toThrow();
// grandchild1 should become a sibling of child1 (at same level)
// grandchild2 should become a child of grandchild1
expect(editor.document).toMatchSnapshot();
});
it("should not throw when unnesting the last deeply nested block", () => {
const editor = withEditor([
{
id: "parent",
type: "paragraph",
content: "Parent",
children: [
{
id: "child1",
type: "paragraph",
content: "Child 1",
children: [
{
id: "grandchild1",
type: "paragraph",
content: "Grandchild 1",
},
{
id: "grandchild2",
type: "paragraph",
content: "Grandchild 2",
},
],
},
],
},
]);
// Unnesting the LAST child should always work (no siblings after)
editor.setTextCursorPosition("grandchild2", "start");
expect(() => {
editor.unnestBlock();
}).not.toThrow();
expect(editor.document).toMatchSnapshot();
});
});
// BLO-953: Backspace at start of indented block loses deeply nested content
// Structure:
// block1
// blockA "text A" ← Backspace at start (unnest via keyboard)
// block3
// block4
// block5
//
// Expected: blockA moves to root, all children preserved
describe("BLO-953: unnest block with multi-level nested children", () => {
it("should preserve all deeply nested content when unnesting", () => {
const editor = withEditor([
{
id: "block1",
type: "paragraph",
content: "Block 1",
children: [
{
id: "blockA",
type: "paragraph",
content: "Block A",
children: [
{
id: "block3",
type: "paragraph",
content: "Block 3",
children: [
{
id: "block4",
type: "paragraph",
content: "Block 4",
},
],
},
{
id: "block5",
type: "paragraph",
content: "Block 5",
},
],
},
],
},
]);
editor.setTextCursorPosition("blockA", "start");
expect(() => {
editor.unnestBlock();
}).not.toThrow();
const doc = editor.document;
// All blocks should still exist in the document
const allBlockIds = flattenBlockIds(doc);
expect(allBlockIds).toContain("block1");
expect(allBlockIds).toContain("blockA");
expect(allBlockIds).toContain("block3");
expect(allBlockIds).toContain("block4");
expect(allBlockIds).toContain("block5");
expect(doc).toMatchSnapshot();
});
it("should preserve content when unnesting only child", () => {
const editor = withEditor([
{
id: "block1",
type: "paragraph",
content: "Block 1",
children: [
{
id: "blockA",
type: "paragraph",
content: "Block A",
children: [
{
id: "block3",
type: "paragraph",
content: "Block 3",
children: [
{
id: "block4",
type: "paragraph",
content: "Block 4",
},
],
},
],
},
],
},
]);
editor.setTextCursorPosition("blockA", "start");
expect(() => {
editor.unnestBlock();
}).not.toThrow();
const doc = editor.document;
const allBlockIds = flattenBlockIds(doc);
expect(allBlockIds).toContain("block1");
expect(allBlockIds).toContain("blockA");
expect(allBlockIds).toContain("block3");
expect(allBlockIds).toContain("block4");
expect(doc).toMatchSnapshot();
});
});
// BLO-844 / BLO-847: Operations after deleting parent cause RangeError
// These bugs manifest when backspace merges/deletes a parent block and
// then further operations on the (now re-parented) children fail.
//
// The core issue is liftListItem failing when the children need to be
// reorganized. Testing the unnest operation directly.
describe("BLO-844/847: unnest with complex nesting after parent operations", () => {
it("should handle unnesting when block is only child", () => {
const editor = withEditor([
{
id: "parent",
type: "paragraph",
content: "Parent",
children: [
{
id: "child",
type: "paragraph",
content: "Child",
},
],
},
]);
editor.setTextCursorPosition("child", "start");
expect(() => {
editor.unnestBlock();
}).not.toThrow();
expect(editor.document).toMatchSnapshot();
});
it("should handle sequential unnest operations", () => {
const editor = withEditor([
{
id: "block1",
type: "paragraph",
content: "Block 1",
children: [
{
id: "block2",
type: "paragraph",
content: "Block 2",
children: [
{
id: "block3",
type: "paragraph",
content: "Block 3",
},
],
},
{
id: "block4",
type: "paragraph",
content: "Block 4",
},
],
},
]);
// First unnest block2
editor.setTextCursorPosition("block2", "start");
expect(() => {
editor.unnestBlock();
}).not.toThrow();
// Then unnest block3 (which should now be child of block2)
editor.setTextCursorPosition("block3", "start");
expect(() => {
editor.unnestBlock();
}).not.toThrow();
expect(editor.document).toMatchSnapshot();
});
});
// Additional edge cases
describe("Edge cases", () => {
it("should not unnest a root-level block", () => {
const editor = withEditor([
{
id: "root-block",
type: "paragraph",
content: "Root Block",
},
]);
editor.setTextCursorPosition("root-block", "start");
// Should be a no-op (can't unnest root level)
const canUnnest = editor.canUnnestBlock();
expect(canUnnest).toBe(false);
});
it("should handle unnesting block with both existing children and siblings after", () => {
const editor = withEditor([
{
id: "parent",
type: "paragraph",
content: "Parent",
children: [
{
id: "child1",
type: "paragraph",
content: "Child 1",
children: [
{
id: "existing-grandchild",
type: "paragraph",
content: "Existing Grandchild",
},
],
},
{
id: "child2",
type: "paragraph",
content: "Child 2",
},
{
id: "child3",
type: "paragraph",
content: "Child 3",
},
],
},
]);
editor.setTextCursorPosition("child1", "start");
expect(() => {
editor.unnestBlock();
}).not.toThrow();
// child1 should be at root level
// existing-grandchild should still be a child of child1
// child2 and child3 should also become children of child1
const doc = editor.document;
const allBlockIds = flattenBlockIds(doc);
expect(allBlockIds).toContain("parent");
expect(allBlockIds).toContain("child1");
expect(allBlockIds).toContain("existing-grandchild");
expect(allBlockIds).toContain("child2");
expect(allBlockIds).toContain("child3");
expect(doc).toMatchSnapshot();
});
it("should handle unnesting with different block types", () => {
const editor = withEditor([
{
id: "parent",
type: "paragraph",
content: "Parent",
children: [
{
id: "heading-child",
type: "heading",
content: "Heading Child",
},
{
id: "para-sibling",
type: "paragraph",
content: "Paragraph Sibling",
},
],
},
]);
editor.setTextCursorPosition("heading-child", "start");
expect(() => {
editor.unnestBlock();
}).not.toThrow();
expect(editor.document).toMatchSnapshot();
});
});
// nestBlock tests (sinkListItem) - ensuring nesting works correctly
describe("nestBlock", () => {
it("should nest a block under its previous sibling", () => {
const editor = withEditor([
{
id: "block1",
type: "paragraph",
content: "Block 1",
},
{
id: "block2",
type: "paragraph",
content: "Block 2",
},
]);
editor.setTextCursorPosition("block2", "start");
editor.nestBlock();
expect(editor.document).toMatchSnapshot();
});
it("should not nest the first block (no previous sibling)", () => {
const editor = withEditor([
{
id: "block1",
type: "paragraph",
content: "Block 1",
},
]);
editor.setTextCursorPosition("block1", "start");
const canNest = editor.canNestBlock();
expect(canNest).toBe(false);
});
it("should nest into a sibling that already has children (nestedBefore)", () => {
const editor = withEditor([
{
id: "block1",
type: "paragraph",
content: "Block 1",
children: [
{
id: "child1",
type: "paragraph",
content: "Child 1",
},
],
},
{
id: "block2",
type: "paragraph",
content: "Block 2",
},
]);
editor.setTextCursorPosition("block2", "start");
editor.nestBlock();
expect(editor.document).toMatchSnapshot();
});
it("nest then unnest should be a round trip", () => {
const editor = withEditor([
{
id: "block1",
type: "paragraph",
content: "Block 1",
},
{
id: "block2",
type: "paragraph",
content: "Block 2",
},
]);
const originalDoc = JSON.parse(JSON.stringify(editor.document));
editor.setTextCursorPosition("block2", "start");
editor.nestBlock();
editor.unnestBlock();
// Content should be preserved (IDs may differ but structure/content same)
expect(editor.document.length).toBe(originalDoc.length);
expect(editor.document[0].content).toEqual(originalDoc[0].content);
expect(editor.document[1].content).toEqual(originalDoc[1].content);
});
});
});
/** Recursively collects all block IDs from a document */
function flattenBlockIds(blocks: any[]): string[] {
const ids: string[] = [];
for (const block of blocks) {
if (block.id) {
ids.push(block.id);
}
if (block.children) {
ids.push(...flattenBlockIds(block.children));
}
}
return ids;
}