diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/__snapshots__/nestBlock.test.ts.snap b/packages/core/src/api/blockManipulation/commands/nestBlock/__snapshots__/nestBlock.test.ts.snap new file mode 100644 index 0000000000..f2e45772c1 --- /dev/null +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/__snapshots__/nestBlock.test.ts.snap @@ -0,0 +1,1095 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`unnestBlock / liftListItem > BLO-835: unnest block with siblings after and nested children > should handle unnesting the first of many siblings 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 1", + "type": "text", + }, + ], + "id": "block1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 3", + "type": "text", + }, + ], + "id": "block3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 4", + "type": "text", + }, + ], + "id": "block4", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Block 2", + "type": "text", + }, + ], + "id": "block2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`unnestBlock / liftListItem > BLO-835: unnest block with siblings after and nested children > should move siblings after into lifted block's children 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 1", + "type": "text", + }, + ], + "id": "block1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 5", + "type": "text", + }, + ], + "id": "block5", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Block 2", + "type": "text", + }, + ], + "id": "block2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`unnestBlock / liftListItem > BLO-835: unnest block with siblings after and nested children > should not throw when unnesting a block that has siblings after it 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 1", + "type": "text", + }, + ], + "id": "block1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 3", + "type": "text", + }, + ], + "id": "block3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 4", + "type": "text", + }, + ], + "id": "block4", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 5", + "type": "text", + }, + ], + "id": "block5", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Block 2", + "type": "text", + }, + ], + "id": "block2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`unnestBlock / liftListItem > BLO-844/847: unnest with complex nesting after parent operations > should handle sequential unnest operations 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 1", + "type": "text", + }, + ], + "id": "block1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 2", + "type": "text", + }, + ], + "id": "block2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 4", + "type": "text", + }, + ], + "id": "block4", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Block 3", + "type": "text", + }, + ], + "id": "block3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`unnestBlock / liftListItem > BLO-844/847: unnest with complex nesting after parent operations > should handle unnesting when block is only child 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Parent", + "type": "text", + }, + ], + "id": "parent", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child", + "type": "text", + }, + ], + "id": "child", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`unnestBlock / liftListItem > BLO-899: Shift-Tab on second-level nested block > should not throw when unnesting a deeply nested block with siblings 1`] = ` +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child 1", + "type": "text", + }, + ], + "id": "child1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Grandchild 2", + "type": "text", + }, + ], + "id": "grandchild2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Grandchild 1", + "type": "text", + }, + ], + "id": "grandchild1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Grandchild 3", + "type": "text", + }, + ], + "id": "grandchild3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Grandchild 4", + "type": "text", + }, + ], + "id": "grandchild4", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Child 2", + "type": "text", + }, + ], + "id": "child2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Parent", + "type": "text", + }, + ], + "id": "parent", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`unnestBlock / liftListItem > BLO-899: Shift-Tab on second-level nested block > should not throw when unnesting the last deeply nested block 1`] = ` +[ + { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Grandchild 1", + "type": "text", + }, + ], + "id": "grandchild1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Child 1", + "type": "text", + }, + ], + "id": "child1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Grandchild 2", + "type": "text", + }, + ], + "id": "grandchild2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Parent", + "type": "text", + }, + ], + "id": "parent", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`unnestBlock / liftListItem > BLO-953: unnest block with multi-level nested children > should preserve all deeply nested content when unnesting 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 1", + "type": "text", + }, + ], + "id": "block1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 4", + "type": "text", + }, + ], + "id": "block4", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Block 3", + "type": "text", + }, + ], + "id": "block3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 5", + "type": "text", + }, + ], + "id": "block5", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Block A", + "type": "text", + }, + ], + "id": "blockA", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`unnestBlock / liftListItem > BLO-953: unnest block with multi-level nested children > should preserve content when unnesting only child 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 1", + "type": "text", + }, + ], + "id": "block1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 4", + "type": "text", + }, + ], + "id": "block4", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Block 3", + "type": "text", + }, + ], + "id": "block3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Block A", + "type": "text", + }, + ], + "id": "blockA", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`unnestBlock / liftListItem > Edge cases > should handle unnesting block with both existing children and siblings after 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Parent", + "type": "text", + }, + ], + "id": "parent", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Existing Grandchild", + "type": "text", + }, + ], + "id": "existing-grandchild", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child 2", + "type": "text", + }, + ], + "id": "child2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child 3", + "type": "text", + }, + ], + "id": "child3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Child 1", + "type": "text", + }, + ], + "id": "child1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`unnestBlock / liftListItem > Edge cases > should handle unnesting with different block types 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Parent", + "type": "text", + }, + ], + "id": "parent", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph Sibling", + "type": "text", + }, + ], + "id": "para-sibling", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Heading Child", + "type": "text", + }, + ], + "id": "heading-child", + "props": { + "backgroundColor": "default", + "isToggleable": false, + "level": 1, + "textAlignment": "left", + "textColor": "default", + }, + "type": "heading", + }, + { + "children": [], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`unnestBlock / liftListItem > nestBlock > should nest a block under its previous sibling 1`] = ` +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 2", + "type": "text", + }, + ], + "id": "block2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Block 1", + "type": "text", + }, + ], + "id": "block1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`unnestBlock / liftListItem > nestBlock > should nest into a sibling that already has children (nestedBefore) 1`] = ` +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child 1", + "type": "text", + }, + ], + "id": "child1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Block 2", + "type": "text", + }, + ], + "id": "block2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Block 1", + "type": "text", + }, + ], + "id": "block1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts new file mode 100644 index 0000000000..1938a3ea80 --- /dev/null +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts @@ -0,0 +1,661 @@ +import { describe, expect, it } from "vitest"; + +import { afterAll, beforeAll } from "vitest"; +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; +} diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts index e8e97d77d3..c995faeda1 100644 --- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts @@ -1,17 +1,21 @@ -import { Fragment, NodeType, Slice } from "prosemirror-model"; +import { Fragment, NodeRange, NodeType, Slice } from "prosemirror-model"; import { Transaction } from "prosemirror-state"; -import { ReplaceAroundStep } from "prosemirror-transform"; +import { canJoin, liftTarget, ReplaceAroundStep } from "prosemirror-transform"; import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; import { getBlockInfoFromTransaction } from "../../../getBlockInfoFromPos.js"; -// TODO: Unit tests /** - * This is a modified version of https://github.com/ProseMirror/prosemirror-schema-list/blob/569c2770cbb8092d8f11ea53ecf78cb7a4e8f15a/src/schema-list.ts#L232 + * Modified version of prosemirror-schema-list's sinkItem. + * https://github.com/ProseMirror/prosemirror-schema-list/blob/master/src/schema-list.ts * - * The original function derives too many information from the parentnode and itemtype + * Changes from the original: + * 1. Range predicate checks node.type instead of firstChild.type + * 2. nestedBefore checks groupType instead of parent.type + * 3. Slice creates groupType instead of parent.type + * 4. Operates on Transaction directly instead of state+dispatch */ -function sinkListItem( +function sinkItem( tr: Transaction, itemType: NodeType, groupType: NodeType, @@ -21,7 +25,7 @@ function sinkListItem( $to, (node) => node.childCount > 0 && - (node.type.name === "blockGroup" || node.type.name === "column"), // change necessary to not look at first item child type + (node.type.name === "blockGroup" || node.type.name === "column"), // change 1 ); if (!range) { return false; @@ -36,11 +40,11 @@ function sinkListItem( return false; } const nestedBefore = - nodeBefore.lastChild && nodeBefore.lastChild.type === groupType; // change necessary to check groupType instead of parent.type + nodeBefore.lastChild && nodeBefore.lastChild.type === groupType; // change 2 const inner = Fragment.from(nestedBefore ? itemType.create() : null); const slice = new Slice( Fragment.from( - itemType.create(null, Fragment.from(groupType.create(null, inner))), // change necessary to create "groupType" instead of parent.type + itemType.create(null, Fragment.from(groupType.create(null, inner))), // change 3 ), nestedBefore ? 3 : 1, 0, @@ -66,7 +70,7 @@ function sinkListItem( export function nestBlock(editor: BlockNoteEditor) { return editor.transact((tr) => { - return sinkListItem( + return sinkItem( tr, editor.pmSchema.nodes["blockContainer"], editor.pmSchema.nodes["blockGroup"], @@ -74,8 +78,121 @@ export function nestBlock(editor: BlockNoteEditor) { }); } +/** + * Modified version of prosemirror-schema-list's liftToOuterList. + * https://github.com/ProseMirror/prosemirror-schema-list/blob/master/src/schema-list.ts + * + * Changes from the original: + * 1. Operates on Transaction directly instead of state+dispatch (TipTap compat) + * 2. When the lifted block already has children (a groupType child), uses deeper + * openStart/offset so siblings merge into the existing group instead of + * creating a second one (which would violate blockContainer's schema) + * 3. Uses groupType.create() instead of range.parent.copy() (same as sinkItem) + */ +function liftToOuterList( + tr: Transaction, + itemType: NodeType, + groupType: NodeType, // change 3 + range: NodeRange, +) { + const end = range.end; + const endOfList = range.$to.end(range.depth); + + if (end < endOfList) { + // There are siblings after the lifted items, which must become + // children of the last item + const blockBeingLifted = range.parent.child(range.endIndex - 1); + const nestedAfter = + blockBeingLifted.lastChild && + blockBeingLifted.lastChild.type === groupType; // change 2 + + tr.step( + new ReplaceAroundStep( + end - (nestedAfter ? 2 : 1), // change 2: go deeper when merging into existing children + endOfList, + end, + endOfList, + new Slice( + Fragment.from( + itemType.create(null, groupType.create()), // change 3 + ), + nestedAfter ? 2 : 1, // change 2: open deeper when merging into existing children + 0, + ), + nestedAfter ? 0 : 1, // change 2: Slice.insertAt offsets by openStart, so 0+2=2 lands inside existing bg + true, + ), + ); + range = new NodeRange( + tr.doc.resolve(range.$from.pos), + tr.doc.resolve(endOfList), + range.depth, + ); + } + + const target = liftTarget(range); + if (target == null) { + return false; + } + + tr.lift(range, target); + + const $after = tr.doc.resolve(tr.mapping.map(end, -1) - 1); + if ( + canJoin(tr.doc, $after.pos) && + $after.nodeBefore!.type === $after.nodeAfter!.type + ) { + tr.join($after.pos); + } + + tr.scrollIntoView(); + return true; +} + +/** + * Modified version of prosemirror-schema-list's liftListItem. + * https://github.com/ProseMirror/prosemirror-schema-list/blob/master/src/schema-list.ts + * + * Changes from the original: + * 1. Range predicate checks node.type instead of firstChild.type (same as sinkItem) + * 2. Passes groupType to liftToOuterList + * 3. Operates on Transaction directly instead of state+dispatch + * 4. Skips liftOutOfList (root-level blocks can't be unnested in BlockNote) + */ +export function liftItem( + tr: Transaction, + itemType: NodeType, + groupType: NodeType, // change 2 +) { + const { $from, $to } = tr.selection; + const range = $from.blockRange( + $to, + (node) => + node.childCount > 0 && + (node.type.name === "blockGroup" || node.type.name === "column"), // change 1 + ); + if (!range) { + return false; + } + + if ($from.node(range.depth - 1).type === itemType) { + // Inside a parent node + return liftToOuterList(tr, itemType, groupType, range); // change 2 + } + + // This is the "liftOutOfList" path — lifting out of a list entirely. + // Not applicable to BlockNote (root-level blocks can't be unnested). // change 4 + return false; +} + export function unnestBlock(editor: BlockNoteEditor) { - editor._tiptapEditor.commands.liftListItem("blockContainer"); + return editor.transact((tr) => + liftItem( + tr, + editor.pmSchema.nodes["blockContainer"], + editor.pmSchema.nodes["blockGroup"], + ), + ); } export function canNestBlock(editor: BlockNoteEditor) { diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index ba1a9df304..99faeab007 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -9,7 +9,11 @@ import { getPrevBlockInfo, mergeBlocksCommand, } from "../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js"; -import { nestBlock } from "../../../api/blockManipulation/commands/nestBlock/nestBlock.js"; +import { + liftItem, + nestBlock, + unnestBlock, +} from "../../../api/blockManipulation/commands/nestBlock/nestBlock.js"; import { fixColumnList } from "../../../api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js"; import { splitBlockCommand } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js"; import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; @@ -63,7 +67,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ }), // Removes a level of nesting if the block is indented if the selection is at the start of the block. () => - commands.command(({ state }) => { + commands.command(({ state, tr }) => { const blockInfo = getBlockInfoFromSelection(state); if (!blockInfo.isBlockContainer) { return false; @@ -74,7 +78,11 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.selection.from === blockContent.beforePos + 1; if (selectionAtBlockStart) { - return commands.liftListItem("blockContainer"); + return liftItem( + tr, + tr.doc.type.schema.nodes["blockContainer"], + tr.doc.type.schema.nodes["blockGroup"], + ); } return false; @@ -734,7 +742,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ // Removes a level of nesting if the block is empty & indented, while the selection is also empty & at the start // of the block. () => - commands.command(({ state }) => { + commands.command(({ state, tr }) => { const blockInfo = getBlockInfoFromSelection(state); if (!blockInfo.isBlockContainer) { return false; @@ -756,7 +764,11 @@ export const KeyboardShortcutsExtension = Extension.create<{ blockEmpty && blockIndented ) { - return commands.liftListItem("blockContainer"); + return liftItem( + tr, + tr.doc.type.schema.nodes["blockContainer"], + tr.doc.type.schema.nodes["blockGroup"], + ); } return false; @@ -926,7 +938,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ // don't handle tabs if a toolbar is shown, so we can tab into / out of it return false; } - return this.editor.commands.liftListItem("blockContainer"); + return unnestBlock(this.options.editor); }, "Shift-Mod-ArrowUp": () => { this.options.editor.moveBlocksUp();