diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts index bb2f08dfca..26774c3d24 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts @@ -11,6 +11,8 @@ import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor"; import { BlockIdentifier } from "../../../../schema/index.js"; import { getNearestBlockPos } from "../../../getBlockInfoFromPos.js"; import { getNodeById } from "../../../nodeUtil.js"; +import { insertBlocks } from "../insertBlocks/insertBlocks.js"; +import { removeAndInsertBlocks } from "../replaceBlocks/replaceBlocks.js"; type BlockSelectionData = ( | { @@ -148,24 +150,26 @@ export function moveBlocks( referenceBlock: BlockIdentifier, placement: "before" | "after", ) { - editor.transact(() => { - // A `columnList` reference can be dissolved by `fixColumnList` when its - // `column`s are removed, leaving its ID invalid for re-insertion. Anchor - // to an adjacent block instead, which is unaffected by the removal. - const refBlock = editor.getBlock(referenceBlock); - if (refBlock?.type === "columnList") { - const adjacent = - placement === "after" - ? editor.getNextBlock(refBlock) - : editor.getPrevBlock(refBlock); - if (adjacent) { - referenceBlock = adjacent; - placement = placement === "after" ? "before" : "after"; - } - } - - editor.removeBlocks(blocks); - editor.insertBlocks(flattenColumns(blocks), referenceBlock, placement); + editor.transact((tr) => { + // Don't fix columns/columnLists in the removal step. Since a move is a + // rearrangement rather than a deletion, columns that it empties out are + // deliberately left as-is instead of being collapsed - this keeps moves + // free of side effects (and reversible by moving back), and matches + // dragging a block out of a column, which doesn't collapse it either. + // Fixing them mid-move also broke the following case: + // + // + // Paragraph + // + // When the non-empty block is moved up, the column is seen as empty and + // collapsed in the removal step, so the following insertion fails. + removeAndInsertBlocks(tr, blocks, [], { fixColumns: false }); + insertBlocks( + tr, + flattenColumns(blocks), + referenceBlock, + placement, + ); }); } diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts index f1e946f909..a4f5b8da89 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -20,6 +20,9 @@ export function removeAndInsertBlocks< tr: Transaction, blocksToRemove: BlockIdentifier[], blocksToInsert: PartialBlock[], + options: { + fixColumns?: boolean; + } = {}, ): { insertedBlocks: Block[]; removedBlocks: Block[]; @@ -112,7 +115,12 @@ export function removeAndInsertBlocks< ); } - columnListPositions.forEach((pos) => fixColumnList(tr, pos)); + // Collapses empty columns/columnLists. Callers where the removal isn't a + // deletion can opt out - e.g. `moveBlocks` re-inserts the blocks elsewhere + // and deliberately leaves emptied columns as-is. + if (options.fixColumns !== false) { + columnListPositions.forEach((pos) => fixColumnList(tr, pos)); + } // Converts the nodes created from `blocksToInsert` into full `Block`s. const insertedBlocks = nodesToInsert.map((node) => diff --git a/packages/xl-multi-column/src/test/commands/__snapshots__/moveBlocks.test.ts.snap b/packages/xl-multi-column/src/test/commands/__snapshots__/moveBlocks.test.ts.snap index 4d6a2cf1f7..d9021ec0e0 100644 --- a/packages/xl-multi-column/src/test/commands/__snapshots__/moveBlocks.test.ts.snap +++ b/packages/xl-multi-column/src/test/commands/__snapshots__/moveBlocks.test.ts.snap @@ -1,5 +1,183 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`Move past empty sibling within a column > Move down below empty sibling 1`] = ` +[ + { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Text 0", + "type": "text", + }, + ], + "id": "text-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "empty-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-empty-0", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [], + "id": "empty-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Text 1", + "type": "text", + }, + ], + "id": "text-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-empty-1", + "props": { + "width": 1, + }, + "type": "column", + }, + ], + "content": undefined, + "id": "column-list-empty", + "props": {}, + "type": "columnList", + }, +] +`; + +exports[`Move past empty sibling within a column > Move up above empty sibling 1`] = ` +[ + { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Text 0", + "type": "text", + }, + ], + "id": "text-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "empty-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-empty-0", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [], + "id": "empty-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Text 1", + "type": "text", + }, + ], + "id": "text-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-empty-1", + "props": { + "width": 1, + }, + "type": "column", + }, + ], + "content": undefined, + "id": "column-list-empty", + "props": {}, + "type": "columnList", + }, +] +`; + exports[`Test moveBlocksDown > Move into column list 1`] = ` [ { @@ -565,21 +743,60 @@ exports[`Test moveBlocksDown > Selection across columns 1`] = ` "type": "paragraph", }, { - "children": [], - "content": [ + "children": [ { - "styles": {}, - "text": "Column Paragraph 0", - "type": "text", + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 0", + "type": "text", + }, + ], + "id": "column-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-0", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [], + "id": "1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "0", + "props": { + "width": 1, + }, + "type": "column", }, ], - "id": "column-paragraph-0", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", + "content": undefined, + "id": "column-list-0", + "props": {}, + "type": "columnList", }, { "children": [], @@ -2067,72 +2284,111 @@ exports[`Test moveBlocksUp > Selection across columns 1`] = ` "type": "paragraph", }, { - "children": [], - "content": [ - { - "styles": {}, - "text": "Column Paragraph 1", - "type": "text", - }, - ], - "id": "column-paragraph-1", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Column Paragraph 2", - "type": "text", - }, - ], - "id": "column-paragraph-2", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": [ + "children": [ { - "styles": {}, - "text": "Column Paragraph 3", - "type": "text", - }, - ], - "id": "column-paragraph-3", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": [ + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 1", + "type": "text", + }, + ], + "id": "column-paragraph-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 2", + "type": "text", + }, + ], + "id": "column-paragraph-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 3", + "type": "text", + }, + ], + "id": "column-paragraph-3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 0", + "type": "text", + }, + ], + "id": "column-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-0", + "props": { + "width": 1, + }, + "type": "column", + }, { - "styles": {}, - "text": "Column Paragraph 0", - "type": "text", + "children": [ + { + "children": [], + "content": [], + "id": "1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "0", + "props": { + "width": 1, + }, + "type": "column", }, ], - "id": "column-paragraph-0", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", + "content": undefined, + "id": "column-list-0", + "props": {}, + "type": "columnList", }, { "children": [], @@ -2379,89 +2635,128 @@ exports[`Test moveBlocksUp > Selection starts in column, ends outside 1`] = ` "type": "paragraph", }, { - "children": [], - "content": [ - { - "styles": {}, - "text": "Column Paragraph 0", - "type": "text", - }, - ], - "id": "column-paragraph-0", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Column Paragraph 1", - "type": "text", - }, - ], - "id": "column-paragraph-1", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Column Paragraph 2", - "type": "text", - }, - ], - "id": "column-paragraph-2", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": [ + "children": [ { - "styles": {}, - "text": "Column Paragraph 3", - "type": "text", + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 0", + "type": "text", + }, + ], + "id": "column-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 1", + "type": "text", + }, + ], + "id": "column-paragraph-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 2", + "type": "text", + }, + ], + "id": "column-paragraph-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 3", + "type": "text", + }, + ], + "id": "column-paragraph-3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 2", + "type": "text", + }, + ], + "id": "paragraph-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-0", + "props": { + "width": 1, + }, + "type": "column", }, - ], - "id": "column-paragraph-3", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": [ { - "styles": {}, - "text": "Paragraph 2", - "type": "text", + "children": [ + { + "children": [], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-1", + "props": { + "width": 1, + }, + "type": "column", }, ], - "id": "paragraph-2", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", + "content": undefined, + "id": "column-list-0", + "props": {}, + "type": "columnList", }, ] `; @@ -2521,89 +2816,128 @@ exports[`Test moveBlocksUp > Selection starts in first column, ends outside 1`] "type": "paragraph", }, { - "children": [], - "content": [ - { - "styles": {}, - "text": "Column Paragraph 1", - "type": "text", - }, - ], - "id": "column-paragraph-1", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Column Paragraph 2", - "type": "text", - }, - ], - "id": "column-paragraph-2", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Column Paragraph 3", - "type": "text", - }, - ], - "id": "column-paragraph-3", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": [ + "children": [ { - "styles": {}, - "text": "Paragraph 2", - "type": "text", + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 1", + "type": "text", + }, + ], + "id": "column-paragraph-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 2", + "type": "text", + }, + ], + "id": "column-paragraph-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 3", + "type": "text", + }, + ], + "id": "column-paragraph-3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 2", + "type": "text", + }, + ], + "id": "paragraph-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 0", + "type": "text", + }, + ], + "id": "column-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-0", + "props": { + "width": 1, + }, + "type": "column", }, - ], - "id": "paragraph-2", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": [ { - "styles": {}, - "text": "Column Paragraph 0", - "type": "text", + "children": [ + { + "children": [], + "content": [], + "id": "1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "0", + "props": { + "width": 1, + }, + "type": "column", }, ], - "id": "column-paragraph-0", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", + "content": undefined, + "id": "column-list-0", + "props": {}, + "type": "columnList", }, ] `; diff --git a/packages/xl-multi-column/src/test/commands/moveBlocks.test.ts b/packages/xl-multi-column/src/test/commands/moveBlocks.test.ts index 156895be44..6970c6c036 100644 --- a/packages/xl-multi-column/src/test/commands/moveBlocks.test.ts +++ b/packages/xl-multi-column/src/test/commands/moveBlocks.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; import { setupTestEnv } from "../setupTestEnv.js"; @@ -151,3 +151,48 @@ describe("Test moveBlocksDown", () => { expect(getEditor().document).toMatchSnapshot(); }); }); + +describe("Move past empty sibling within a column", () => { + beforeEach(() => { + getEditor().replaceBlocks(getEditor().document, [ + { + id: "column-list-empty", + type: "columnList", + children: [ + { + id: "column-empty-0", + type: "column", + children: [ + { id: "empty-0", type: "paragraph" }, + { id: "text-0", type: "paragraph", content: "Text 0" }, + ], + }, + { + id: "column-empty-1", + type: "column", + children: [ + { id: "empty-1", type: "paragraph" }, + { id: "text-1", type: "paragraph", content: "Text 1" }, + ], + }, + ], + }, + ]); + }); + + it("Move up above empty sibling", () => { + getEditor().setTextCursorPosition("text-0"); + + expect(() => getEditor().moveBlocksUp()).not.toThrow(); + + expect(getEditor().document).toMatchSnapshot(); + }); + + it("Move down below empty sibling", () => { + getEditor().setTextCursorPosition("empty-0"); + + expect(() => getEditor().moveBlocksDown()).not.toThrow(); + + expect(getEditor().document).toMatchSnapshot(); + }); +});