Skip to content

Commit ff199d9

Browse files
feat: Clean up invalid multi-column handling (#2113)
* Added `fixColumnList` function * Updated keyboard shortcut * Implemented PR feedback
1 parent a521b8f commit ff199d9

File tree

6 files changed

+913
-224
lines changed

6 files changed

+913
-224
lines changed

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

Lines changed: 24 additions & 219 deletions
Original file line numberDiff line numberDiff line change
@@ -1,165 +1,16 @@
1-
import { Fragment, ResolvedPos, Slice, type Node } from "prosemirror-model";
2-
import { TextSelection, type Transaction } from "prosemirror-state";
3-
import { ReplaceAroundStep } from "prosemirror-transform";
1+
import { type Node } from "prosemirror-model";
2+
import { type Transaction } from "prosemirror-state";
43
import type { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js";
54
import type {
65
BlockIdentifier,
76
BlockSchema,
87
InlineContentSchema,
98
StyleSchema,
109
} from "../../../../schema/index.js";
11-
import { getBlockInfoFromResolvedPos } from "../../../getBlockInfoFromPos.js";
1210
import { blockToNode } from "../../../nodeConversions/blockToNode.js";
1311
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
1412
import { getPmSchema } from "../../../pmUtil.js";
15-
import {
16-
getParentBlockInfo,
17-
getPrevBlockInfo,
18-
} from "../mergeBlocks/mergeBlocks.js";
19-
20-
// TODO: Where should this function go?
21-
/**
22-
* Moves the first block in a column to the previous/next column and handles
23-
* all necessary collapsing of `column`/`columnList` nodes. Only moves the
24-
* block to the start of the next column if it's in the first column.
25-
* Otherwise, moves the block to the end of the previous column.
26-
* @param tr The transaction to apply changes to.
27-
* @param blockBeforePos The position just before the first block in the column.
28-
* @returns The position just before the block, after it's moved.
29-
*/
30-
export function moveFirstBlockInColumn(
31-
tr: Transaction,
32-
blockBeforePos: ResolvedPos,
33-
): ResolvedPos {
34-
const blockInfo = getBlockInfoFromResolvedPos(blockBeforePos);
35-
if (!blockInfo.isBlockContainer) {
36-
throw new Error(
37-
"Invalid blockBeforePos: does not point to blockContainer node.",
38-
);
39-
}
40-
41-
const prevBlockInfo = getPrevBlockInfo(tr.doc, blockInfo.bnBlock.beforePos);
42-
if (prevBlockInfo) {
43-
throw new Error(
44-
"Invalid blockBeforePos: does not point to first blockContainer node in column.",
45-
);
46-
}
47-
48-
const parentBlockInfo = getParentBlockInfo(
49-
tr.doc,
50-
blockInfo.bnBlock.beforePos,
51-
);
52-
if (parentBlockInfo?.blockNoteType !== "column") {
53-
throw new Error(
54-
"Invalid blockBeforePos: blockContainer node is not child of column.",
55-
);
56-
}
57-
58-
const column = parentBlockInfo;
59-
const columnList = getParentBlockInfo(tr.doc, column.bnBlock.beforePos);
60-
if (columnList?.blockNoteType !== "columnList") {
61-
throw new Error(
62-
"Invalid blockBeforePos: blockContainer node is child of column, but column is not child of columnList node.",
63-
);
64-
}
65-
66-
const shouldRemoveColumn = column.childContainer!.node.childCount === 1;
67-
68-
const shouldRemoveColumnList =
69-
shouldRemoveColumn && columnList.childContainer!.node.childCount === 2;
70-
71-
const isFirstColumn =
72-
columnList.childContainer!.node.firstChild === column.bnBlock.node;
73-
74-
const blockToMove = tr.doc.slice(
75-
blockInfo.bnBlock.beforePos,
76-
blockInfo.bnBlock.afterPos,
77-
false,
78-
);
79-
80-
/*
81-
There are 3 different cases:
82-
a) remove entire column list (if no columns would be remaining)
83-
b) remove just a column (if no blocks inside a column would be remaining)
84-
c) keep columns (if there are blocks remaining inside a column)
85-
86-
Each of these 3 cases has 2 sub-cases, depending on whether the backspace happens at the start of the first (most-left) column,
87-
or at the start of a non-first column.
88-
*/
89-
if (shouldRemoveColumnList) {
90-
if (isFirstColumn) {
91-
tr.step(
92-
new ReplaceAroundStep(
93-
// replace entire column list
94-
columnList.bnBlock.beforePos,
95-
columnList.bnBlock.afterPos,
96-
// select content of remaining column:
97-
column.bnBlock.afterPos + 1,
98-
columnList.bnBlock.afterPos - 2,
99-
blockToMove,
100-
blockToMove.size, // append existing content to blockToMove
101-
false,
102-
),
103-
);
104-
const pos = tr.doc.resolve(columnList.bnBlock.beforePos);
105-
tr.setSelection(TextSelection.between(pos, pos));
106-
107-
return pos;
108-
} else {
109-
// replaces the column list with the blockToMove slice, prepended with the content of the remaining column
110-
tr.step(
111-
new ReplaceAroundStep(
112-
// replace entire column list
113-
columnList.bnBlock.beforePos,
114-
columnList.bnBlock.afterPos,
115-
// select content of existing column:
116-
columnList.bnBlock.beforePos + 2,
117-
column.bnBlock.beforePos - 1,
118-
blockToMove,
119-
0, // prepend existing content to blockToMove
120-
false,
121-
),
122-
);
123-
const pos = tr.doc.resolve(tr.mapping.map(column.bnBlock.beforePos - 1));
124-
tr.setSelection(TextSelection.between(pos, pos));
125-
126-
return pos;
127-
}
128-
} else if (shouldRemoveColumn) {
129-
if (isFirstColumn) {
130-
// delete column
131-
tr.delete(column.bnBlock.beforePos, column.bnBlock.afterPos);
132-
133-
// move before columnlist
134-
tr.insert(columnList.bnBlock.beforePos, blockToMove.content);
135-
136-
const pos = tr.doc.resolve(columnList.bnBlock.beforePos);
137-
tr.setSelection(TextSelection.between(pos, pos));
138-
139-
return pos;
140-
} else {
141-
// just delete the </column><column> closing and opening tags to merge the columns
142-
tr.delete(column.bnBlock.beforePos - 1, column.bnBlock.beforePos + 1);
143-
const pos = tr.doc.resolve(column.bnBlock.beforePos - 1);
144-
145-
return pos;
146-
}
147-
} else {
148-
// delete block
149-
tr.delete(blockInfo.bnBlock.beforePos, blockInfo.bnBlock.afterPos);
150-
if (isFirstColumn) {
151-
// move before columnlist
152-
tr.insert(columnList.bnBlock.beforePos - 1, blockToMove.content);
153-
} else {
154-
// append block to previous column
155-
tr.insert(column.bnBlock.beforePos - 1, blockToMove.content);
156-
}
157-
const pos = tr.doc.resolve(column.bnBlock.beforePos - 1);
158-
tr.setSelection(TextSelection.between(pos, pos));
159-
160-
return pos;
161-
}
162-
}
13+
import { fixColumnList } from "./util/fixColumnList.js";
16314

16415
export function removeAndInsertBlocks<
16516
BSchema extends BlockSchema,
@@ -222,85 +73,32 @@ export function removeAndInsertBlocks<
22273
const oldDocSize = tr.doc.nodeSize;
22374

22475
const $pos = tr.doc.resolve(pos - removedSize);
225-
if ($pos.node().type.name === "column" && $pos.node().childCount === 1) {
226-
// Checks if the block is the only child of a parent `column` node. In
227-
// this case, we need to collapse the `column` or parent `columnList`,
228-
// depending on if the `columnList` has more than 2 children. This is
229-
// handled by `moveFirstBlockInColumn`.
230-
const $newPos = moveFirstBlockInColumn(tr, $pos);
231-
// Instead of deleting it, `moveFirstBlockInColumn` moves the block in
232-
// order to handle the columns after, so we have to delete it manually.
233-
tr.replace(
234-
$newPos.pos,
235-
$newPos.pos + $newPos.nodeAfter!.nodeSize,
236-
Slice.empty,
237-
);
238-
} else if (
239-
$pos.node().type.name === "columnList" &&
240-
$pos.node().childCount === 2
241-
) {
242-
// Checks whether removing the entire column would leave only a single
243-
// remaining `column` node in the columnList. In this case, we need to
244-
// collapse the column list.
245-
const column = getBlockInfoFromResolvedPos($pos);
246-
if (column.blockNoteType !== "column") {
247-
throw new Error(
248-
`Invalid block: ${column.blockNoteType} was found as child of columnList.`,
249-
);
250-
}
251-
const columnList = getParentBlockInfo(tr.doc, column.bnBlock.beforePos);
252-
if (!columnList) {
253-
throw new Error(
254-
`Invalid block: column was found without a parent columnList.`,
255-
);
256-
}
257-
if (columnList?.blockNoteType !== "columnList") {
258-
throw new Error(
259-
`Invalid block: ${columnList.blockNoteType} was found as a parent of column.`,
260-
);
261-
}
262-
263-
if ($pos.node().childCount === 1) {
264-
tr.replaceWith(
265-
columnList.bnBlock.beforePos,
266-
columnList.bnBlock.afterPos,
267-
Fragment.empty,
268-
);
269-
}
270-
271-
tr.replaceWith(
272-
columnList.bnBlock.beforePos,
273-
columnList.bnBlock.afterPos,
274-
$pos.index() === 0
275-
? columnList.bnBlock.node.lastChild!.content
276-
: columnList.bnBlock.node.firstChild!.content,
277-
);
278-
} else if (
76+
if (
27977
node.type.name === "column" &&
28078
node.attrs.id !== $pos.nodeAfter?.attrs.id
28179
) {
282-
// This is a hacky work around to handle an edge case with the previous
283-
// `if else` block. When each `column` of a `columnList` is in the
284-
// `blocksToRemove` array, this is what happens once all but the last 2
285-
// columns are removed:
80+
// This is a hacky work around to handle removing all columns in a
81+
// columnList. This is what happens when removing the last 2 columns:
28682
//
28783
// 1. The second-to-last `column` is removed.
288-
// 2. The last `column` and wrapping `columnList` are collapsed.
289-
// 3. `removedSize` increases by the size of the removed column, and more
290-
// due to positions at the starts/ends of the last `column` and wrapping
291-
// `columnList` also getting removed.
84+
// 2. `fixColumnList` runs, removing the `columnList` and inserting the
85+
// contents of the last column in its place.
86+
// 3. `removedSize` increases not just by the size of the second-to-last
87+
// `column`, but also by the positions removed due to running
88+
// `fixColumnList`. Some of these positions are after the contents of the
89+
// last `column`, namely just after the `column` and `columnList`.
29290
// 3. `tr.doc.descendants` traverses to the last `column`.
29391
// 4. `removedSize` now includes positions that were removed after the
294-
// last `column`. In order for `pos - removedSize` to correctly point to
295-
// the start of the nodes that were previously wrapped by the last
296-
// `column`, `removedPos` must only include positions removed before it.
92+
// last `column`. This causes `pos - removedSize` to point to an
93+
// incorrect position, as it expects that the difference in document size
94+
// accounted for by `removedSize` comes before the block being removed.
29795
// 5. The deletion is offset by 3, because of those removed positions
29896
// included in `removedSize` that occur after the last `column`.
29997
//
30098
// Hence why we have to shift the start of the deletion range back by 3.
30199
// The offset for the end of the range is smaller as `node.nodeSize` is
302-
// the size of the whole second `column`, whereas now we are left with
303-
// just its children since it's collapsed - a difference of 2 positions.
100+
// the size of the second `column`. Since it's been removed, we actually
101+
// care about the size of its children - a difference of 2 positions.
304102
tr.delete(pos - removedSize + 3, pos - removedSize + node.nodeSize + 1);
305103
} else if (
306104
$pos.node().type.name === "blockGroup" &&
@@ -314,6 +112,13 @@ export function removeAndInsertBlocks<
314112
} else {
315113
tr.delete(pos - removedSize, pos - removedSize + node.nodeSize);
316114
}
115+
116+
if ($pos.node().type.name === "column") {
117+
fixColumnList(tr, $pos.before(-1));
118+
} else if ($pos.node().type.name === "columnList") {
119+
fixColumnList(tr, $pos.before());
120+
}
121+
317122
const newDocSize = tr.doc.nodeSize;
318123
removedSize += oldDocSize - newDocSize;
319124

0 commit comments

Comments
 (0)