From fcc0d123149ef736c207ab78fe88267b645c1e14 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 17 Oct 2025 15:28:17 +0200 Subject: [PATCH 1/3] Added `fixColumnList` function --- .../commands/replaceBlocks/replaceBlocks.ts | 228 ++++++++++++------ 1 file changed, 160 insertions(+), 68 deletions(-) diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts index d41a20230f..7e4b724ca4 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -1,4 +1,4 @@ -import { Fragment, ResolvedPos, Slice, type Node } from "prosemirror-model"; +import { ResolvedPos, type Node } from "prosemirror-model"; import { TextSelection, type Transaction } from "prosemirror-state"; import { ReplaceAroundStep } from "prosemirror-transform"; import type { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js"; @@ -161,6 +161,144 @@ export function moveFirstBlockInColumn( } } +/** + * Checks if a `column` node is empty, i.e. if it has only a single empty + * block. + * @param column The column to check. + * @returns Whether the column is empty. + */ +function isEmptyColumn(column: Node) { + if (!column || column.type.name !== "column") { + throw new Error("Invalid columnPos: does not point to column node."); + } + + const blockContainer = column.firstChild; + if (!blockContainer) { + throw new Error("Invalid column: does not have child node."); + } + + const blockContent = blockContainer.firstChild; + if (!blockContent) { + throw new Error("Invalid blockContainer: does not have child node."); + } + + return ( + column.childCount === 1 && + blockContainer.childCount === 1 && + blockContent.type.spec.content === "inline*" && + blockContent.content.content.length === 0 + ); +} + +/** + * Removes all empty `column` nodes in a `columnList`. A `column` node is empty + * if it has only a single empty block. If, however, removing the `column`s + * leaves the `columnList` that has fewer than two, ProseMirror will re-add + * empty columns. + * @param tr The `Transaction` to add the changes to. + * @param columnListPos The position just before the `columnList` node. + */ +function removeEmptyColumns(tr: Transaction, columnListPos: number) { + const $columnListPos = tr.doc.resolve(columnListPos); + const columnList = $columnListPos.nodeAfter; + if (!columnList || columnList.type.name !== "columnList") { + throw new Error( + "Invalid columnListPos: does not point to columnList node.", + ); + } + + for ( + let columnIndex = columnList.childCount - 1; + columnIndex >= 0; + columnIndex-- + ) { + const columnPos = tr.doc + .resolve($columnListPos.pos + 1) + .posAtIndex(columnIndex); + const $columnPos = tr.doc.resolve(columnPos); + const column = $columnPos.nodeAfter; + if (!column || column.type.name !== "column") { + throw new Error("Invalid columnPos: does not point to column node."); + } + + if (isEmptyColumn(column)) { + tr.delete(columnPos, columnPos + column?.nodeSize); + } + } +} + +/** + * Fixes potential issues in a `columnList` node after a + * `blockContainer`/`column` node is (re)moved from it: + * + * - Removes all empty `column` nodes. A `column` node is empty if it has only + * a single empty block. + * - If all but one `column` nodes are empty, replaces the `columnList` with + * the content of the non-empty `column`. + * - If all `column` nodes are empty, removes the `columnList` entirely. + * @param tr The `Transaction` to add the changes to. + * @param columnListPos + * @returns The position just before the `columnList` node. + */ +export function fixColumnList(tr: Transaction, columnListPos: number) { + removeEmptyColumns(tr, columnListPos); + + const $columnListPos = tr.doc.resolve(columnListPos); + const columnList = $columnListPos.nodeAfter; + if (!columnList || columnList.type.name !== "columnList") { + throw new Error( + "Invalid columnListPos: does not point to columnList node.", + ); + } + + if (columnList.childCount > 2) { + return; + } + + const firstColumnBeforePos = columnListPos + 1; + const $firstColumnBeforePos = tr.doc.resolve(firstColumnBeforePos); + const firstColumn = $firstColumnBeforePos.nodeAfter; + const lastColumnAfterPos = columnListPos + columnList.nodeSize - 1; + const $lastColumnAfterPos = tr.doc.resolve(lastColumnAfterPos); + const lastColumn = $lastColumnAfterPos.nodeBefore; + if (!firstColumn || !lastColumn) { + throw new Error("Invalid columnList: does not have child node."); + } + + const firstColumnEmpty = isEmptyColumn(firstColumn); + const lastColumnEmpty = isEmptyColumn(lastColumn); + + if (firstColumnEmpty && lastColumnEmpty) { + tr.delete(columnListPos, columnListPos + columnList.nodeSize); + + return; + } + + if (firstColumnEmpty) { + const lastColumnContent = tr.doc.slice( + lastColumnAfterPos - lastColumn.nodeSize + 1, + lastColumnAfterPos - 1, + ); + + tr.delete(columnListPos, columnListPos + columnList.nodeSize); + tr.insert(columnListPos, lastColumnContent.content); + + return; + } + + if (lastColumnEmpty) { + const firstColumnContent = tr.doc.slice( + firstColumnBeforePos + 1, + firstColumnBeforePos + firstColumn.nodeSize - 1, + ); + + tr.delete(columnListPos, columnListPos + columnList.nodeSize); + tr.insert(columnListPos, firstColumnContent.content); + + return; + } +} + export function removeAndInsertBlocks< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -222,85 +360,32 @@ export function removeAndInsertBlocks< const oldDocSize = tr.doc.nodeSize; const $pos = tr.doc.resolve(pos - removedSize); - if ($pos.node().type.name === "column" && $pos.node().childCount === 1) { - // Checks if the block is the only child of a parent `column` node. In - // this case, we need to collapse the `column` or parent `columnList`, - // depending on if the `columnList` has more than 2 children. This is - // handled by `moveFirstBlockInColumn`. - const $newPos = moveFirstBlockInColumn(tr, $pos); - // Instead of deleting it, `moveFirstBlockInColumn` moves the block in - // order to handle the columns after, so we have to delete it manually. - tr.replace( - $newPos.pos, - $newPos.pos + $newPos.nodeAfter!.nodeSize, - Slice.empty, - ); - } else if ( - $pos.node().type.name === "columnList" && - $pos.node().childCount === 2 - ) { - // Checks whether removing the entire column would leave only a single - // remaining `column` node in the columnList. In this case, we need to - // collapse the column list. - const column = getBlockInfoFromResolvedPos($pos); - if (column.blockNoteType !== "column") { - throw new Error( - `Invalid block: ${column.blockNoteType} was found as child of columnList.`, - ); - } - const columnList = getParentBlockInfo(tr.doc, column.bnBlock.beforePos); - if (!columnList) { - throw new Error( - `Invalid block: column was found without a parent columnList.`, - ); - } - if (columnList?.blockNoteType !== "columnList") { - throw new Error( - `Invalid block: ${columnList.blockNoteType} was found as a parent of column.`, - ); - } - - if ($pos.node().childCount === 1) { - tr.replaceWith( - columnList.bnBlock.beforePos, - columnList.bnBlock.afterPos, - Fragment.empty, - ); - } - - tr.replaceWith( - columnList.bnBlock.beforePos, - columnList.bnBlock.afterPos, - $pos.index() === 0 - ? columnList.bnBlock.node.lastChild!.content - : columnList.bnBlock.node.firstChild!.content, - ); - } else if ( + if ( node.type.name === "column" && node.attrs.id !== $pos.nodeAfter?.attrs.id ) { - // This is a hacky work around to handle an edge case with the previous - // `if else` block. When each `column` of a `columnList` is in the - // `blocksToRemove` array, this is what happens once all but the last 2 - // columns are removed: + // This is a hacky work around to handle removing all columns in a + // columnList. This is what happens when removing the last 2 columns: // // 1. The second-to-last `column` is removed. - // 2. The last `column` and wrapping `columnList` are collapsed. - // 3. `removedSize` increases by the size of the removed column, and more - // due to positions at the starts/ends of the last `column` and wrapping - // `columnList` also getting removed. + // 2. `fixColumnList` runs, removing the `columnList` and inserting the + // contents of the last column in its place. + // 3. `removedSize` increases not just by the size of the second-to-last + // `column`, but also by the positions removed due to running + // `fixColumnList`. Some of these positions are after the contents of the + // last `column`, namely just after the `column` and `columnList`. // 3. `tr.doc.descendants` traverses to the last `column`. // 4. `removedSize` now includes positions that were removed after the - // last `column`. In order for `pos - removedSize` to correctly point to - // the start of the nodes that were previously wrapped by the last - // `column`, `removedPos` must only include positions removed before it. + // last `column`. This causes `pos - removedSize` to point to an + // incorrect position, as it expects that the difference in document size + // accounted for by `removedSize` comes before the block being removed. // 5. The deletion is offset by 3, because of those removed positions // included in `removedSize` that occur after the last `column`. // // Hence why we have to shift the start of the deletion range back by 3. // The offset for the end of the range is smaller as `node.nodeSize` is - // the size of the whole second `column`, whereas now we are left with - // just its children since it's collapsed - a difference of 2 positions. + // the size of the second `column`. Since it's been removed, we actually + // care about the size of its children - a difference of 2 positions. tr.delete(pos - removedSize + 3, pos - removedSize + node.nodeSize + 1); } else if ( $pos.node().type.name === "blockGroup" && @@ -314,6 +399,13 @@ export function removeAndInsertBlocks< } else { tr.delete(pos - removedSize, pos - removedSize + node.nodeSize); } + + if ($pos.node().type.name === "column") { + fixColumnList(tr, $pos.before(-1)); + } else if ($pos.node().type.name === "columnList") { + fixColumnList(tr, $pos.before()); + } + const newDocSize = tr.doc.nodeSize; removedSize += oldDocSize - newDocSize; From 38d2f7471cd0da959a929930d53a75bc95125b4e Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 20 Oct 2025 12:28:31 +0200 Subject: [PATCH 2/3] Updated keyboard shortcut --- .../commands/replaceBlocks/replaceBlocks.ts | 194 +++--------------- .../KeyboardShortcutsExtension.ts | 33 ++- 2 files changed, 61 insertions(+), 166 deletions(-) diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts index 7e4b724ca4..be735a99fa 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -1,5 +1,5 @@ -import { ResolvedPos, type Node } from "prosemirror-model"; -import { TextSelection, type Transaction } from "prosemirror-state"; +import { Slice, type Node } from "prosemirror-model"; +import { type Transaction } from "prosemirror-state"; import { ReplaceAroundStep } from "prosemirror-transform"; import type { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js"; import type { @@ -8,158 +8,9 @@ import type { InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; -import { getBlockInfoFromResolvedPos } from "../../../getBlockInfoFromPos.js"; import { blockToNode } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getPmSchema } from "../../../pmUtil.js"; -import { - getParentBlockInfo, - getPrevBlockInfo, -} from "../mergeBlocks/mergeBlocks.js"; - -// TODO: Where should this function go? -/** - * Moves the first block in a column to the previous/next column and handles - * all necessary collapsing of `column`/`columnList` nodes. Only moves the - * block to the start of the next column if it's in the first column. - * Otherwise, moves the block to the end of the previous column. - * @param tr The transaction to apply changes to. - * @param blockBeforePos The position just before the first block in the column. - * @returns The position just before the block, after it's moved. - */ -export function moveFirstBlockInColumn( - tr: Transaction, - blockBeforePos: ResolvedPos, -): ResolvedPos { - const blockInfo = getBlockInfoFromResolvedPos(blockBeforePos); - if (!blockInfo.isBlockContainer) { - throw new Error( - "Invalid blockBeforePos: does not point to blockContainer node.", - ); - } - - const prevBlockInfo = getPrevBlockInfo(tr.doc, blockInfo.bnBlock.beforePos); - if (prevBlockInfo) { - throw new Error( - "Invalid blockBeforePos: does not point to first blockContainer node in column.", - ); - } - - const parentBlockInfo = getParentBlockInfo( - tr.doc, - blockInfo.bnBlock.beforePos, - ); - if (parentBlockInfo?.blockNoteType !== "column") { - throw new Error( - "Invalid blockBeforePos: blockContainer node is not child of column.", - ); - } - - const column = parentBlockInfo; - const columnList = getParentBlockInfo(tr.doc, column.bnBlock.beforePos); - if (columnList?.blockNoteType !== "columnList") { - throw new Error( - "Invalid blockBeforePos: blockContainer node is child of column, but column is not child of columnList node.", - ); - } - - const shouldRemoveColumn = column.childContainer!.node.childCount === 1; - - const shouldRemoveColumnList = - shouldRemoveColumn && columnList.childContainer!.node.childCount === 2; - - const isFirstColumn = - columnList.childContainer!.node.firstChild === column.bnBlock.node; - - const blockToMove = tr.doc.slice( - blockInfo.bnBlock.beforePos, - blockInfo.bnBlock.afterPos, - false, - ); - - /* - There are 3 different cases: - a) remove entire column list (if no columns would be remaining) - b) remove just a column (if no blocks inside a column would be remaining) - c) keep columns (if there are blocks remaining inside a column) - - Each of these 3 cases has 2 sub-cases, depending on whether the backspace happens at the start of the first (most-left) column, - or at the start of a non-first column. - */ - if (shouldRemoveColumnList) { - if (isFirstColumn) { - tr.step( - new ReplaceAroundStep( - // replace entire column list - columnList.bnBlock.beforePos, - columnList.bnBlock.afterPos, - // select content of remaining column: - column.bnBlock.afterPos + 1, - columnList.bnBlock.afterPos - 2, - blockToMove, - blockToMove.size, // append existing content to blockToMove - false, - ), - ); - const pos = tr.doc.resolve(columnList.bnBlock.beforePos); - tr.setSelection(TextSelection.between(pos, pos)); - - return pos; - } else { - // replaces the column list with the blockToMove slice, prepended with the content of the remaining column - tr.step( - new ReplaceAroundStep( - // replace entire column list - columnList.bnBlock.beforePos, - columnList.bnBlock.afterPos, - // select content of existing column: - columnList.bnBlock.beforePos + 2, - column.bnBlock.beforePos - 1, - blockToMove, - 0, // prepend existing content to blockToMove - false, - ), - ); - const pos = tr.doc.resolve(tr.mapping.map(column.bnBlock.beforePos - 1)); - tr.setSelection(TextSelection.between(pos, pos)); - - return pos; - } - } else if (shouldRemoveColumn) { - if (isFirstColumn) { - // delete column - tr.delete(column.bnBlock.beforePos, column.bnBlock.afterPos); - - // move before columnlist - tr.insert(columnList.bnBlock.beforePos, blockToMove.content); - - const pos = tr.doc.resolve(columnList.bnBlock.beforePos); - tr.setSelection(TextSelection.between(pos, pos)); - - return pos; - } else { - // just delete the closing and opening tags to merge the columns - tr.delete(column.bnBlock.beforePos - 1, column.bnBlock.beforePos + 1); - const pos = tr.doc.resolve(column.bnBlock.beforePos - 1); - - return pos; - } - } else { - // delete block - tr.delete(blockInfo.bnBlock.beforePos, blockInfo.bnBlock.afterPos); - if (isFirstColumn) { - // move before columnlist - tr.insert(columnList.bnBlock.beforePos - 1, blockToMove.content); - } else { - // append block to previous column - tr.insert(column.bnBlock.beforePos - 1, blockToMove.content); - } - const pos = tr.doc.resolve(column.bnBlock.beforePos - 1); - tr.setSelection(TextSelection.between(pos, pos)); - - return pos; - } -} /** * Checks if a `column` node is empty, i.e. if it has only a single empty @@ -258,9 +109,11 @@ export function fixColumnList(tr: Transaction, columnListPos: number) { const firstColumnBeforePos = columnListPos + 1; const $firstColumnBeforePos = tr.doc.resolve(firstColumnBeforePos); const firstColumn = $firstColumnBeforePos.nodeAfter; + const lastColumnAfterPos = columnListPos + columnList.nodeSize - 1; const $lastColumnAfterPos = tr.doc.resolve(lastColumnAfterPos); const lastColumn = $lastColumnAfterPos.nodeBefore; + if (!firstColumn || !lastColumn) { throw new Error("Invalid columnList: does not have child node."); } @@ -269,32 +122,47 @@ export function fixColumnList(tr: Transaction, columnListPos: number) { const lastColumnEmpty = isEmptyColumn(lastColumn); if (firstColumnEmpty && lastColumnEmpty) { + // Removes `columnList` tr.delete(columnListPos, columnListPos + columnList.nodeSize); return; } if (firstColumnEmpty) { - const lastColumnContent = tr.doc.slice( - lastColumnAfterPos - lastColumn.nodeSize + 1, - lastColumnAfterPos - 1, + tr.step( + new ReplaceAroundStep( + // Replaces `columnList`. + columnListPos, + columnListPos + columnList.nodeSize, + // Replaces with content of last `column`. + lastColumnAfterPos - lastColumn.nodeSize + 1, + lastColumnAfterPos - 1, + // Doesn't append anything. + Slice.empty, + 0, + false, + ), ); - tr.delete(columnListPos, columnListPos + columnList.nodeSize); - tr.insert(columnListPos, lastColumnContent.content); - return; } if (lastColumnEmpty) { - const firstColumnContent = tr.doc.slice( - firstColumnBeforePos + 1, - firstColumnBeforePos + firstColumn.nodeSize - 1, + tr.step( + new ReplaceAroundStep( + // Replaces `columnList`. + columnListPos, + columnListPos + columnList.nodeSize, + // Replaces with content of first `column`. + firstColumnBeforePos + 1, + firstColumnBeforePos + firstColumn.nodeSize - 1, + // Doesn't append anything. + Slice.empty, + 0, + false, + ), ); - tr.delete(columnListPos, columnListPos + columnList.nodeSize); - tr.insert(columnListPos, firstColumnContent.content); - return; } } diff --git a/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index a7180afed3..6a3ae20e32 100644 --- a/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -11,7 +11,7 @@ import { splitBlockCommand } from "../../api/blockManipulation/commands/splitBlo import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { moveFirstBlockInColumn } from "../../api/blockManipulation/commands/replaceBlocks/replaceBlocks.js"; +import { fixColumnList } from "../../api/blockManipulation/commands/replaceBlocks/replaceBlocks.js"; export const KeyboardShortcutsExtension = Extension.create<{ editor: BlockNoteEditor; @@ -24,7 +24,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ addKeyboardShortcuts() { // handleBackspace is partially adapted from https://github.com/ueberdosis/tiptap/blob/ed56337470efb4fd277128ab7ef792b37cfae992/packages/core/src/extensions/keymap.ts const handleBackspace = () => - this.editor.commands.first(({ chain, commands }) => [ + this.editor.commands.first(({ chain, commands, tr }) => [ // Deletes the selection if it's not empty. () => commands.deleteSelection(), // Undoes an input rule if one was triggered in the last editor state change. @@ -123,8 +123,35 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } + const $blockPos = state.doc.resolve(blockInfo.bnBlock.beforePos); + const $columnPos = state.doc.resolve($blockPos.before(-1)); + const columnListPos = $columnPos.before(); + if (dispatch) { - moveFirstBlockInColumn(state.tr, $pos); + const fragment = state.doc.slice( + blockInfo.bnBlock.beforePos, + blockInfo.bnBlock.afterPos, + ).content; + + tr.delete( + blockInfo.bnBlock.beforePos, + blockInfo.bnBlock.afterPos, + ); + + if ($columnPos.index() === 0) { + // Fix `columnList` and insert the block before it. + fixColumnList(state.tr, columnListPos); + tr.insert(columnListPos, fragment); + tr.setSelection(TextSelection.create(tr.doc, columnListPos)); + } else { + // Insert the block at the end of the first column and fix + // `columnList`. + tr.insert($columnPos.pos - 1, fragment); + tr.setSelection( + TextSelection.create(tr.doc, $columnPos.pos - 1), + ); + fixColumnList(tr, columnListPos); + } } return true; From 218f22a1885cbced9092c4dea749e39e1023655e Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 20 Oct 2025 19:29:39 +0200 Subject: [PATCH 3/3] Implemented PR feedback --- .../commands/replaceBlocks/replaceBlocks.ts | 159 +------ .../replaceBlocks/util/fixColumnList.ts | 163 +++++++ .../KeyboardShortcutsExtension.ts | 24 +- packages/core/src/index.ts | 1 + .../__snapshots__/fixColumnLists.test.ts.snap | 408 ++++++++++++++++++ .../test/commands/util/fixColumnLists.test.ts | 283 ++++++++++++ 6 files changed, 870 insertions(+), 168 deletions(-) create mode 100644 packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts create mode 100644 packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap create mode 100644 packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts index be735a99fa..579c4d8c6b 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -1,6 +1,5 @@ -import { Slice, type Node } from "prosemirror-model"; +import { type Node } from "prosemirror-model"; import { type Transaction } from "prosemirror-state"; -import { ReplaceAroundStep } from "prosemirror-transform"; import type { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js"; import type { BlockIdentifier, @@ -11,161 +10,7 @@ import type { import { blockToNode } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getPmSchema } from "../../../pmUtil.js"; - -/** - * Checks if a `column` node is empty, i.e. if it has only a single empty - * block. - * @param column The column to check. - * @returns Whether the column is empty. - */ -function isEmptyColumn(column: Node) { - if (!column || column.type.name !== "column") { - throw new Error("Invalid columnPos: does not point to column node."); - } - - const blockContainer = column.firstChild; - if (!blockContainer) { - throw new Error("Invalid column: does not have child node."); - } - - const blockContent = blockContainer.firstChild; - if (!blockContent) { - throw new Error("Invalid blockContainer: does not have child node."); - } - - return ( - column.childCount === 1 && - blockContainer.childCount === 1 && - blockContent.type.spec.content === "inline*" && - blockContent.content.content.length === 0 - ); -} - -/** - * Removes all empty `column` nodes in a `columnList`. A `column` node is empty - * if it has only a single empty block. If, however, removing the `column`s - * leaves the `columnList` that has fewer than two, ProseMirror will re-add - * empty columns. - * @param tr The `Transaction` to add the changes to. - * @param columnListPos The position just before the `columnList` node. - */ -function removeEmptyColumns(tr: Transaction, columnListPos: number) { - const $columnListPos = tr.doc.resolve(columnListPos); - const columnList = $columnListPos.nodeAfter; - if (!columnList || columnList.type.name !== "columnList") { - throw new Error( - "Invalid columnListPos: does not point to columnList node.", - ); - } - - for ( - let columnIndex = columnList.childCount - 1; - columnIndex >= 0; - columnIndex-- - ) { - const columnPos = tr.doc - .resolve($columnListPos.pos + 1) - .posAtIndex(columnIndex); - const $columnPos = tr.doc.resolve(columnPos); - const column = $columnPos.nodeAfter; - if (!column || column.type.name !== "column") { - throw new Error("Invalid columnPos: does not point to column node."); - } - - if (isEmptyColumn(column)) { - tr.delete(columnPos, columnPos + column?.nodeSize); - } - } -} - -/** - * Fixes potential issues in a `columnList` node after a - * `blockContainer`/`column` node is (re)moved from it: - * - * - Removes all empty `column` nodes. A `column` node is empty if it has only - * a single empty block. - * - If all but one `column` nodes are empty, replaces the `columnList` with - * the content of the non-empty `column`. - * - If all `column` nodes are empty, removes the `columnList` entirely. - * @param tr The `Transaction` to add the changes to. - * @param columnListPos - * @returns The position just before the `columnList` node. - */ -export function fixColumnList(tr: Transaction, columnListPos: number) { - removeEmptyColumns(tr, columnListPos); - - const $columnListPos = tr.doc.resolve(columnListPos); - const columnList = $columnListPos.nodeAfter; - if (!columnList || columnList.type.name !== "columnList") { - throw new Error( - "Invalid columnListPos: does not point to columnList node.", - ); - } - - if (columnList.childCount > 2) { - return; - } - - const firstColumnBeforePos = columnListPos + 1; - const $firstColumnBeforePos = tr.doc.resolve(firstColumnBeforePos); - const firstColumn = $firstColumnBeforePos.nodeAfter; - - const lastColumnAfterPos = columnListPos + columnList.nodeSize - 1; - const $lastColumnAfterPos = tr.doc.resolve(lastColumnAfterPos); - const lastColumn = $lastColumnAfterPos.nodeBefore; - - if (!firstColumn || !lastColumn) { - throw new Error("Invalid columnList: does not have child node."); - } - - const firstColumnEmpty = isEmptyColumn(firstColumn); - const lastColumnEmpty = isEmptyColumn(lastColumn); - - if (firstColumnEmpty && lastColumnEmpty) { - // Removes `columnList` - tr.delete(columnListPos, columnListPos + columnList.nodeSize); - - return; - } - - if (firstColumnEmpty) { - tr.step( - new ReplaceAroundStep( - // Replaces `columnList`. - columnListPos, - columnListPos + columnList.nodeSize, - // Replaces with content of last `column`. - lastColumnAfterPos - lastColumn.nodeSize + 1, - lastColumnAfterPos - 1, - // Doesn't append anything. - Slice.empty, - 0, - false, - ), - ); - - return; - } - - if (lastColumnEmpty) { - tr.step( - new ReplaceAroundStep( - // Replaces `columnList`. - columnListPos, - columnListPos + columnList.nodeSize, - // Replaces with content of first `column`. - firstColumnBeforePos + 1, - firstColumnBeforePos + firstColumn.nodeSize - 1, - // Doesn't append anything. - Slice.empty, - 0, - false, - ), - ); - - return; - } -} +import { fixColumnList } from "./util/fixColumnList.js"; export function removeAndInsertBlocks< BSchema extends BlockSchema, diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts new file mode 100644 index 0000000000..adaf3d7f69 --- /dev/null +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts @@ -0,0 +1,163 @@ +import { Slice, type Node } from "prosemirror-model"; +import { type Transaction } from "prosemirror-state"; +import { ReplaceAroundStep } from "prosemirror-transform"; + +/** + * Checks if a `column` node is empty, i.e. if it has only a single empty + * block. + * @param column The column to check. + * @returns Whether the column is empty. + */ +export function isEmptyColumn(column: Node) { + if (!column || column.type.name !== "column") { + throw new Error("Invalid columnPos: does not point to column node."); + } + + const blockContainer = column.firstChild; + if (!blockContainer) { + throw new Error("Invalid column: does not have child node."); + } + + const blockContent = blockContainer.firstChild; + if (!blockContent) { + throw new Error("Invalid blockContainer: does not have child node."); + } + + return ( + column.childCount === 1 && + blockContainer.childCount === 1 && + blockContent.type.spec.content === "inline*" && + blockContent.content.content.length === 0 + ); +} + +/** + * Removes all empty `column` nodes in a `columnList`. A `column` node is empty + * if it has only a single empty block. If, however, removing the `column`s + * leaves the `columnList` that has fewer than two, ProseMirror will re-add + * empty columns. + * @param tr The `Transaction` to add the changes to. + * @param columnListPos The position just before the `columnList` node. + */ +export function removeEmptyColumns(tr: Transaction, columnListPos: number) { + const $columnListPos = tr.doc.resolve(columnListPos); + const columnList = $columnListPos.nodeAfter; + if (!columnList || columnList.type.name !== "columnList") { + throw new Error( + "Invalid columnListPos: does not point to columnList node.", + ); + } + + for ( + let columnIndex = columnList.childCount - 1; + columnIndex >= 0; + columnIndex-- + ) { + const columnPos = tr.doc + .resolve($columnListPos.pos + 1) + .posAtIndex(columnIndex); + const $columnPos = tr.doc.resolve(columnPos); + const column = $columnPos.nodeAfter; + if (!column || column.type.name !== "column") { + throw new Error("Invalid columnPos: does not point to column node."); + } + + if (isEmptyColumn(column)) { + tr.delete(columnPos, columnPos + column?.nodeSize); + } + } +} + +/** + * Fixes potential issues in a `columnList` node after a + * `blockContainer`/`column` node is (re)moved from it: + * + * - Removes all empty `column` nodes. A `column` node is empty if it has only + * a single empty block. + * - If all but one `column` nodes are empty, replaces the `columnList` with + * the content of the non-empty `column`. + * - If all `column` nodes are empty, removes the `columnList` entirely. + * @param tr The `Transaction` to add the changes to. + * @param columnListPos + * @returns The position just before the `columnList` node. + */ +export function fixColumnList(tr: Transaction, columnListPos: number) { + removeEmptyColumns(tr, columnListPos); + + const $columnListPos = tr.doc.resolve(columnListPos); + const columnList = $columnListPos.nodeAfter; + if (!columnList || columnList.type.name !== "columnList") { + throw new Error( + "Invalid columnListPos: does not point to columnList node.", + ); + } + + if (columnList.childCount > 2) { + // Do nothing if the `columnList` has at least two non-empty `column`s. + return; + } + + if (columnList.childCount < 2) { + throw new Error("Invalid columnList: contains fewer than two children."); + } + + const firstColumnBeforePos = columnListPos + 1; + const $firstColumnBeforePos = tr.doc.resolve(firstColumnBeforePos); + const firstColumn = $firstColumnBeforePos.nodeAfter; + + const lastColumnAfterPos = columnListPos + columnList.nodeSize - 1; + const $lastColumnAfterPos = tr.doc.resolve(lastColumnAfterPos); + const lastColumn = $lastColumnAfterPos.nodeBefore; + + if (!firstColumn || !lastColumn) { + throw new Error("Invalid columnList: does not contain children."); + } + + const firstColumnEmpty = isEmptyColumn(firstColumn); + const lastColumnEmpty = isEmptyColumn(lastColumn); + + if (firstColumnEmpty && lastColumnEmpty) { + // Removes `columnList` + tr.delete(columnListPos, columnListPos + columnList.nodeSize); + + return; + } + + if (firstColumnEmpty) { + tr.step( + new ReplaceAroundStep( + // Replaces `columnList`. + columnListPos, + columnListPos + columnList.nodeSize, + // Replaces with content of last `column`. + lastColumnAfterPos - lastColumn.nodeSize + 1, + lastColumnAfterPos - 1, + // Doesn't append anything. + Slice.empty, + 0, + false, + ), + ); + + return; + } + + if (lastColumnEmpty) { + tr.step( + new ReplaceAroundStep( + // Replaces `columnList`. + columnListPos, + columnListPos + columnList.nodeSize, + // Replaces with content of first `column`. + firstColumnBeforePos + 1, + firstColumnBeforePos + firstColumn.nodeSize - 1, + // Doesn't append anything. + Slice.empty, + 0, + false, + ), + ); + + return; + } +} diff --git a/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 6a3ae20e32..da63452923 100644 --- a/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -11,7 +11,7 @@ import { splitBlockCommand } from "../../api/blockManipulation/commands/splitBlo import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { fixColumnList } from "../../api/blockManipulation/commands/replaceBlocks/replaceBlocks.js"; +import { fixColumnList } from "../../api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js"; export const KeyboardShortcutsExtension = Extension.create<{ editor: BlockNoteEditor; @@ -24,7 +24,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ addKeyboardShortcuts() { // handleBackspace is partially adapted from https://github.com/ueberdosis/tiptap/blob/ed56337470efb4fd277128ab7ef792b37cfae992/packages/core/src/extensions/keymap.ts const handleBackspace = () => - this.editor.commands.first(({ chain, commands, tr }) => [ + this.editor.commands.first(({ chain, commands }) => [ // Deletes the selection if it's not empty. () => commands.deleteSelection(), // Undoes an input rule if one was triggered in the last editor state change. @@ -97,7 +97,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), () => - commands.command(({ state, dispatch }) => { + commands.command(({ state, tr, dispatch }) => { // when at the start of a first block in a column const blockInfo = getBlockInfoFromSelection(state); if (!blockInfo.isBlockContainer) { @@ -105,12 +105,12 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const selectionAtBlockStart = - state.selection.from === blockInfo.blockContent.beforePos + 1; + tr.selection.from === blockInfo.blockContent.beforePos + 1; if (!selectionAtBlockStart) { return false; } - const $pos = state.doc.resolve(blockInfo.bnBlock.beforePos); + const $pos = tr.doc.resolve(blockInfo.bnBlock.beforePos); const prevBlock = $pos.nodeBefore; if (prevBlock) { @@ -123,12 +123,12 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } - const $blockPos = state.doc.resolve(blockInfo.bnBlock.beforePos); - const $columnPos = state.doc.resolve($blockPos.before(-1)); + const $blockPos = tr.doc.resolve(blockInfo.bnBlock.beforePos); + const $columnPos = tr.doc.resolve($blockPos.before()); const columnListPos = $columnPos.before(); if (dispatch) { - const fragment = state.doc.slice( + const fragment = tr.doc.slice( blockInfo.bnBlock.beforePos, blockInfo.bnBlock.afterPos, ).content; @@ -140,15 +140,17 @@ export const KeyboardShortcutsExtension = Extension.create<{ if ($columnPos.index() === 0) { // Fix `columnList` and insert the block before it. - fixColumnList(state.tr, columnListPos); + fixColumnList(tr, columnListPos); tr.insert(columnListPos, fragment); - tr.setSelection(TextSelection.create(tr.doc, columnListPos)); + tr.setSelection( + TextSelection.near(tr.doc.resolve(columnListPos)), + ); } else { // Insert the block at the end of the first column and fix // `columnList`. tr.insert($columnPos.pos - 1, fragment); tr.setSelection( - TextSelection.create(tr.doc, $columnPos.pos - 1), + TextSelection.near(tr.doc.resolve($columnPos.pos - 1)), ); fixColumnList(tr, columnListPos); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 11fe0e5460..4b6994d5d4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ export * from "./api/blockManipulation/commands/insertBlocks/insertBlocks.js"; export * from "./api/blockManipulation/commands/replaceBlocks/replaceBlocks.js"; export * from "./api/blockManipulation/commands/updateBlock/updateBlock.js"; +export * from "./api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js"; export * from "./api/exporters/html/externalHTMLExporter.js"; export * from "./api/exporters/html/internalHTMLSerializer.js"; export * from "./api/getBlockInfoFromPos.js"; diff --git a/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap new file mode 100644 index 0000000000..87b5f2e588 --- /dev/null +++ b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap @@ -0,0 +1,408 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Test fixColumnList > First of two columns empty 1`] = ` +{ + "content": [ + { + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "content": [ + { + "text": "Paragraph 1", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "blockGroup", + }, + ], + "type": "doc", +} +`; + +exports[`Test fixColumnList > Last of two columns empty 1`] = ` +{ + "content": [ + { + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "content": [ + { + "text": "Paragraph 1", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "blockGroup", + }, + ], + "type": "doc", +} +`; + +exports[`Test fixColumnList > Two empty columns 1`] = ` +{ + "content": [ + { + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "blockGroup", + }, + ], + "type": "doc", +} +`; + +exports[`Test removeEmptyColumns > First of two columns empty 1`] = ` +{ + "content": [ + { + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "id": null, + "width": 1, + }, + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "column", + }, + { + "attrs": { + "id": null, + "width": 1, + }, + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "content": [ + { + "text": "Paragraph 1", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "column", + }, + ], + "type": "columnList", + }, + ], + "type": "blockGroup", + }, + ], + "type": "doc", +} +`; + +exports[`Test removeEmptyColumns > Last of two columns empty 1`] = ` +{ + "content": [ + { + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "id": null, + "width": 1, + }, + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "content": [ + { + "text": "Paragraph 1", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "column", + }, + { + "attrs": { + "id": null, + "width": 1, + }, + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "column", + }, + ], + "type": "columnList", + }, + ], + "type": "blockGroup", + }, + ], + "type": "doc", +} +`; + +exports[`Test removeEmptyColumns > Start and end columns empty 1`] = ` +{ + "content": [ + { + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "id": null, + "width": 1, + }, + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "content": [ + { + "text": "Paragraph 1", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "column", + }, + { + "attrs": { + "id": null, + "width": 1, + }, + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "content": [ + { + "text": "Paragraph 2", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "column", + }, + ], + "type": "columnList", + }, + ], + "type": "blockGroup", + }, + ], + "type": "doc", +} +`; + +exports[`Test removeEmptyColumns > Two empty columns 1`] = ` +{ + "content": [ + { + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "id": null, + "width": 1, + }, + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "column", + }, + { + "attrs": { + "id": null, + "width": 1, + }, + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "column", + }, + ], + "type": "columnList", + }, + ], + "type": "blockGroup", + }, + ], + "type": "doc", +} +`; diff --git a/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts b/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts new file mode 100644 index 0000000000..5212030057 --- /dev/null +++ b/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from "vitest"; + +import { setupTestEnv } from "../../setupTestEnv.js"; +import { + fixColumnList, + isEmptyColumn, + removeEmptyColumns, +} from "@blocknote/core"; + +const getEditor = setupTestEnv(); + +describe("Test isEmptyColumn", () => { + it("Empty blocks", () => { + const schema = getEditor()._tiptapEditor.schema; + + const column = schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(), + ]), + ]); + + expect(isEmptyColumn(column)).toBeTruthy(); + }); + + it("Multiple blocks", () => { + const schema = getEditor()._tiptapEditor.schema; + + const column = schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(undefined), + ]), + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(), + ]), + ]); + + expect(isEmptyColumn(column)).toBeFalsy(); + }); + + it("Block with children", () => { + const schema = getEditor()._tiptapEditor.schema; + + const column = schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(undefined), + schema.nodes["blockGroup"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(), + ]), + ]), + ]), + ]); + + expect(isEmptyColumn(column)).toBeFalsy(); + }); + + it("Block with text", () => { + const schema = getEditor()._tiptapEditor.schema; + + const column = schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(undefined, [ + schema.text("Paragraph 1"), + ]), + ]), + ]); + + expect(isEmptyColumn(column)).toBeFalsy(); + }); + + it("Non-text block", () => { + const schema = getEditor()._tiptapEditor.schema; + + const column = schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["image"].create(), + ]), + ]); + + expect(isEmptyColumn(column)).toBeFalsy(); + }); +}); + +describe("Test removeEmptyColumns", () => { + it("Start and end columns empty", () => { + const editor = getEditor(); + const schema = editor._tiptapEditor.schema; + + const columnList = schema.nodes["columnList"].create(undefined, [ + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(), + ]), + ]), + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(undefined, [ + schema.text("Paragraph 1"), + ]), + ]), + ]), + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(undefined, [ + schema.text("Paragraph 2"), + ]), + ]), + ]), + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(), + ]), + ]), + ]); + + const tr = editor.prosemirrorState.tr; + + tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); + removeEmptyColumns(tr, 1); + + expect(tr.doc).toMatchSnapshot(); + }); + + it("First of two columns empty", () => { + const editor = getEditor(); + const schema = editor._tiptapEditor.schema; + + const columnList = schema.nodes["columnList"].create(undefined, [ + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(), + ]), + ]), + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(undefined, [ + schema.text("Paragraph 1"), + ]), + ]), + ]), + ]); + + const tr = editor.prosemirrorState.tr; + + tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); + removeEmptyColumns(tr, 1); + + expect(tr.doc).toMatchSnapshot(); + }); + + it("Last of two columns empty", () => { + const editor = getEditor(); + const schema = editor._tiptapEditor.schema; + + const columnList = schema.nodes["columnList"].create(undefined, [ + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(undefined, [ + schema.text("Paragraph 1"), + ]), + ]), + ]), + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(), + ]), + ]), + ]); + + const tr = editor.prosemirrorState.tr; + + tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); + removeEmptyColumns(tr, 1); + + expect(tr.doc).toMatchSnapshot(); + }); + + it("Two empty columns", () => { + const editor = getEditor(); + const schema = editor._tiptapEditor.schema; + + const columnList = schema.nodes["columnList"].create(undefined, [ + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(), + ]), + ]), + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(), + ]), + ]), + ]); + + const tr = editor.prosemirrorState.tr; + + tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); + removeEmptyColumns(tr, 1); + + expect(tr.doc).toMatchSnapshot(); + }); +}); + +describe("Test fixColumnList", () => { + it("First of two columns empty", () => { + const editor = getEditor(); + const schema = editor._tiptapEditor.schema; + + const columnList = schema.nodes["columnList"].create(undefined, [ + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(), + ]), + ]), + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(undefined, [ + schema.text("Paragraph 1"), + ]), + ]), + ]), + ]); + + const tr = editor.prosemirrorState.tr; + + tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); + fixColumnList(tr, 1); + + expect(tr.doc).toMatchSnapshot(); + }); + + it("Last of two columns empty", () => { + const editor = getEditor(); + const schema = editor._tiptapEditor.schema; + + const columnList = schema.nodes["columnList"].create(undefined, [ + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(undefined, [ + schema.text("Paragraph 1"), + ]), + ]), + ]), + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(), + ]), + ]), + ]); + + const tr = editor.prosemirrorState.tr; + + tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); + fixColumnList(tr, 1); + + expect(tr.doc).toMatchSnapshot(); + }); + + it("Two empty columns", () => { + const editor = getEditor(); + const schema = editor._tiptapEditor.schema; + + const columnList = schema.nodes["columnList"].create(undefined, [ + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(), + ]), + ]), + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(), + ]), + ]), + ]); + + const tr = editor.prosemirrorState.tr; + + tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); + fixColumnList(tr, 1); + + expect(tr.doc).toMatchSnapshot(); + }); +});