Skip to content

Commit 87f4ada

Browse files
fix: deleting last block in column (#2110)
1 parent aaa1577 commit 87f4ada

File tree

10 files changed

+2047
-229
lines changed

10 files changed

+2047
-229
lines changed

packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { Node } from "prosemirror-model";
2-
import type { Transaction } from "prosemirror-state";
1+
import { type Node } from "prosemirror-model";
2+
import { type Transaction } from "prosemirror-state";
33
import type { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js";
44
import type {
55
BlockIdentifier,
@@ -10,6 +10,7 @@ import type {
1010
import { blockToNode } from "../../../nodeConversions/blockToNode.js";
1111
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
1212
import { getPmSchema } from "../../../pmUtil.js";
13+
import { fixColumnList } from "./util/fixColumnList.js";
1314

1415
export function removeAndInsertBlocks<
1516
BSchema extends BlockSchema,
@@ -36,6 +37,7 @@ export function removeAndInsertBlocks<
3637
),
3738
);
3839
const removedBlocks: Block<BSchema, I, S>[] = [];
40+
const columnListPositions = new Set<number>();
3941

4042
const idOfFirstBlock =
4143
typeof blocksToRemove[0] === "string"
@@ -70,26 +72,35 @@ export function removeAndInsertBlocks<
7072
}
7173

7274
const oldDocSize = tr.doc.nodeSize;
73-
// Checks if the block is the only child of its parent. In this case, we
74-
// need to delete the parent `blockGroup` node instead of just the
75-
// `blockContainer`.
75+
7676
const $pos = tr.doc.resolve(pos - removedSize);
77+
78+
if ($pos.node().type.name === "column") {
79+
columnListPositions.add($pos.before(-1));
80+
} else if ($pos.node().type.name === "columnList") {
81+
columnListPositions.add($pos.before());
82+
}
83+
7784
if (
7885
$pos.node().type.name === "blockGroup" &&
7986
$pos.node($pos.depth - 1).type.name !== "doc" &&
8087
$pos.node().childCount === 1
8188
) {
89+
// Checks if the block is the only child of a parent `blockGroup` node.
90+
// In this case, we need to delete the parent `blockGroup` node instead
91+
// of just the `blockContainer`.
8292
tr.delete($pos.before(), $pos.after());
8393
} else {
8494
tr.delete(pos - removedSize, pos - removedSize + node.nodeSize);
8595
}
96+
8697
const newDocSize = tr.doc.nodeSize;
8798
removedSize += oldDocSize - newDocSize;
8899

89100
return false;
90101
});
91102

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

@@ -99,6 +110,8 @@ export function removeAndInsertBlocks<
99110
);
100111
}
101112

113+
columnListPositions.forEach((pos) => fixColumnList(tr, pos));
114+
102115
// Converts the nodes created from `blocksToInsert` into full `Block`s.
103116
const insertedBlocks = nodesToInsert.map((node) =>
104117
nodeToBlock(node, pmSchema),
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { Slice, type Node } from "prosemirror-model";
2+
import { type Transaction } from "prosemirror-state";
3+
import { ReplaceAroundStep } from "prosemirror-transform";
4+
5+
/**
6+
* Checks if a `column` node is empty, i.e. if it has only a single empty
7+
* paragraph.
8+
* @param column The column to check.
9+
* @returns Whether the column is empty.
10+
*/
11+
export function isEmptyColumn(column: Node) {
12+
if (!column || column.type.name !== "column") {
13+
throw new Error("Invalid columnPos: does not point to column node.");
14+
}
15+
16+
const blockContainer = column.firstChild;
17+
if (!blockContainer) {
18+
throw new Error("Invalid column: does not have child node.");
19+
}
20+
21+
const blockContent = blockContainer.firstChild;
22+
if (!blockContent) {
23+
throw new Error("Invalid blockContainer: does not have child node.");
24+
}
25+
26+
return (
27+
column.childCount === 1 &&
28+
blockContainer.childCount === 1 &&
29+
blockContent.type.name === "paragraph" &&
30+
blockContent.content.content.length === 0
31+
);
32+
}
33+
34+
/**
35+
* Removes all empty `column` nodes in a `columnList`. A `column` node is empty
36+
* if it has only a single empty block. If, however, removing the `column`s
37+
* leaves the `columnList` that has fewer than two, ProseMirror will re-add
38+
* empty columns.
39+
* @param tr The `Transaction` to add the changes to.
40+
* @param columnListPos The position just before the `columnList` node.
41+
*/
42+
export function removeEmptyColumns(tr: Transaction, columnListPos: number) {
43+
const $columnListPos = tr.doc.resolve(columnListPos);
44+
const columnList = $columnListPos.nodeAfter;
45+
if (!columnList || columnList.type.name !== "columnList") {
46+
throw new Error(
47+
"Invalid columnListPos: does not point to columnList node.",
48+
);
49+
}
50+
51+
for (
52+
let columnIndex = columnList.childCount - 1;
53+
columnIndex >= 0;
54+
columnIndex--
55+
) {
56+
const columnPos = tr.doc
57+
.resolve($columnListPos.pos + 1)
58+
.posAtIndex(columnIndex);
59+
const $columnPos = tr.doc.resolve(columnPos);
60+
const column = $columnPos.nodeAfter;
61+
if (!column || column.type.name !== "column") {
62+
throw new Error("Invalid columnPos: does not point to column node.");
63+
}
64+
65+
if (isEmptyColumn(column)) {
66+
tr.delete(columnPos, columnPos + column.nodeSize);
67+
}
68+
}
69+
}
70+
71+
/**
72+
* Fixes potential issues in a `columnList` node after a
73+
* `blockContainer`/`column` node is (re)moved from it:
74+
*
75+
* - Removes all empty `column` nodes. A `column` node is empty if it has only
76+
* a single empty block.
77+
* - If all but one `column` nodes are empty, replaces the `columnList` with
78+
* the content of the non-empty `column`.
79+
* - If all `column` nodes are empty, removes the `columnList` entirely.
80+
* @param tr The `Transaction` to add the changes to.
81+
* @param columnListPos
82+
* @returns The position just before the `columnList` node.
83+
*/
84+
export function fixColumnList(tr: Transaction, columnListPos: number) {
85+
removeEmptyColumns(tr, columnListPos);
86+
87+
const $columnListPos = tr.doc.resolve(columnListPos);
88+
const columnList = $columnListPos.nodeAfter;
89+
if (!columnList || columnList.type.name !== "columnList") {
90+
throw new Error(
91+
"Invalid columnListPos: does not point to columnList node.",
92+
);
93+
}
94+
95+
if (columnList.childCount > 2) {
96+
// Do nothing if the `columnList` has more than two non-empty `column`s. In
97+
// the case that the `columnList` has exactly two columns, we may need to
98+
// still remove it, as it's possible that one or both columns are empty.
99+
// This is because after `removeEmptyColumns` is called, if the
100+
// `columnList` has fewer than two `column`s, ProseMirror will re-add empty
101+
// `column`s until there are two total, in order to fit the schema.
102+
return;
103+
}
104+
105+
if (columnList.childCount < 2) {
106+
// Throw an error if the `columnList` has fewer than two columns. After
107+
// `removeEmptyColumns` is called, if the `columnList` has fewer than two
108+
// `column`s, ProseMirror will re-add empty `column`s until there are two
109+
// total, in order to fit the schema. So if there are fewer than two here,
110+
// either the schema, or ProseMirror's internals, must have changed.
111+
throw new Error("Invalid columnList: contains fewer than two children.");
112+
}
113+
114+
const firstColumnBeforePos = columnListPos + 1;
115+
const $firstColumnBeforePos = tr.doc.resolve(firstColumnBeforePos);
116+
const firstColumn = $firstColumnBeforePos.nodeAfter;
117+
118+
const lastColumnAfterPos = columnListPos + columnList.nodeSize - 1;
119+
const $lastColumnAfterPos = tr.doc.resolve(lastColumnAfterPos);
120+
const lastColumn = $lastColumnAfterPos.nodeBefore;
121+
122+
if (!firstColumn || !lastColumn) {
123+
throw new Error("Invalid columnList: does not contain children.");
124+
}
125+
126+
const firstColumnEmpty = isEmptyColumn(firstColumn);
127+
const lastColumnEmpty = isEmptyColumn(lastColumn);
128+
129+
if (firstColumnEmpty && lastColumnEmpty) {
130+
// Removes `columnList`
131+
tr.delete(columnListPos, columnListPos + columnList.nodeSize);
132+
133+
return;
134+
}
135+
136+
if (firstColumnEmpty) {
137+
tr.step(
138+
new ReplaceAroundStep(
139+
// Replaces `columnList`.
140+
columnListPos,
141+
columnListPos + columnList.nodeSize,
142+
// Replaces with content of last `column`.
143+
lastColumnAfterPos - lastColumn.nodeSize + 1,
144+
lastColumnAfterPos - 1,
145+
// Doesn't append anything.
146+
Slice.empty,
147+
0,
148+
false,
149+
),
150+
);
151+
152+
return;
153+
}
154+
155+
if (lastColumnEmpty) {
156+
tr.step(
157+
new ReplaceAroundStep(
158+
// Replaces `columnList`.
159+
columnListPos,
160+
columnListPos + columnList.nodeSize,
161+
// Replaces with content of first `column`.
162+
firstColumnBeforePos + 1,
163+
firstColumnBeforePos + firstColumn.nodeSize - 1,
164+
// Doesn't append anything.
165+
Slice.empty,
166+
0,
167+
false,
168+
),
169+
);
170+
171+
return;
172+
}
173+
}

0 commit comments

Comments
 (0)