Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Node } from "prosemirror-model";
import type { Transaction } from "prosemirror-state";
import { type Node } from "prosemirror-model";
import { type Transaction } from "prosemirror-state";
import type { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js";
import type {
BlockIdentifier,
Expand All @@ -10,6 +10,7 @@ import type {
import { blockToNode } from "../../../nodeConversions/blockToNode.js";
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
import { getPmSchema } from "../../../pmUtil.js";
import { fixColumnList } from "./util/fixColumnList.js";

export function removeAndInsertBlocks<
BSchema extends BlockSchema,
Expand All @@ -36,6 +37,7 @@ export function removeAndInsertBlocks<
),
);
const removedBlocks: Block<BSchema, I, S>[] = [];
const columnListPositions = new Set<number>();

const idOfFirstBlock =
typeof blocksToRemove[0] === "string"
Expand Down Expand Up @@ -70,26 +72,35 @@ export function removeAndInsertBlocks<
}

const oldDocSize = tr.doc.nodeSize;
// Checks if the block is the only child of its parent. In this case, we
// need to delete the parent `blockGroup` node instead of just the
// `blockContainer`.

const $pos = tr.doc.resolve(pos - removedSize);

if ($pos.node().type.name === "column") {
columnListPositions.add($pos.before(-1));
} else if ($pos.node().type.name === "columnList") {
columnListPositions.add($pos.before());
}

if (
$pos.node().type.name === "blockGroup" &&
$pos.node($pos.depth - 1).type.name !== "doc" &&
$pos.node().childCount === 1
) {
// Checks if the block is the only child of a parent `blockGroup` node.
// In this case, we need to delete the parent `blockGroup` node instead
// of just the `blockContainer`.
tr.delete($pos.before(), $pos.after());
} else {
tr.delete(pos - removedSize, pos - removedSize + node.nodeSize);
}

const newDocSize = tr.doc.nodeSize;
removedSize += oldDocSize - newDocSize;

return false;
});

// Throws an error if now all blocks could be found.
// Throws an error if not all blocks could be found.
if (idsOfBlocksToRemove.size > 0) {
const notFoundIds = [...idsOfBlocksToRemove].join("\n");

Expand All @@ -99,6 +110,8 @@ export function removeAndInsertBlocks<
);
}

columnListPositions.forEach((pos) => fixColumnList(tr, pos));

// Converts the nodes created from `blocksToInsert` into full `Block`s.
const insertedBlocks = nodesToInsert.map((node) =>
nodeToBlock(node, pmSchema),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
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
* paragraph.
* @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.name === "paragraph" &&
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 more than two non-empty `column`s. In
// the case that the `columnList` has exactly two columns, we may need to
// still remove it, as it's possible that one or both columns are empty.
// This is because after `removeEmptyColumns` is called, if the
// `columnList` has fewer than two `column`s, ProseMirror will re-add empty
// `column`s until there are two total, in order to fit the schema.
return;
}

if (columnList.childCount < 2) {
// Throw an error if the `columnList` has fewer than two columns. After
// `removeEmptyColumns` is called, if the `columnList` has fewer than two
// `column`s, ProseMirror will re-add empty `column`s until there are two
// total, in order to fit the schema. So if there are fewer than two here,
// either the schema, or ProseMirror's internals, must have changed.
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;
}
}
Loading
Loading