1- import { Fragment , ResolvedPos , Slice , type Node } from "prosemirror-model" ;
1+ import { ResolvedPos , type Node } from "prosemirror-model" ;
22import { TextSelection , type Transaction } from "prosemirror-state" ;
33import { ReplaceAroundStep } from "prosemirror-transform" ;
44import type { Block , PartialBlock } from "../../../../blocks/defaultBlocks.js" ;
@@ -161,6 +161,144 @@ export function moveFirstBlockInColumn(
161161 }
162162}
163163
164+ /**
165+ * Checks if a `column` node is empty, i.e. if it has only a single empty
166+ * block.
167+ * @param column The column to check.
168+ * @returns Whether the column is empty.
169+ */
170+ function isEmptyColumn ( column : Node ) {
171+ if ( ! column || column . type . name !== "column" ) {
172+ throw new Error ( "Invalid columnPos: does not point to column node." ) ;
173+ }
174+
175+ const blockContainer = column . firstChild ;
176+ if ( ! blockContainer ) {
177+ throw new Error ( "Invalid column: does not have child node." ) ;
178+ }
179+
180+ const blockContent = blockContainer . firstChild ;
181+ if ( ! blockContent ) {
182+ throw new Error ( "Invalid blockContainer: does not have child node." ) ;
183+ }
184+
185+ return (
186+ column . childCount === 1 &&
187+ blockContainer . childCount === 1 &&
188+ blockContent . type . spec . content === "inline*" &&
189+ blockContent . content . content . length === 0
190+ ) ;
191+ }
192+
193+ /**
194+ * Removes all empty `column` nodes in a `columnList`. A `column` node is empty
195+ * if it has only a single empty block. If, however, removing the `column`s
196+ * leaves the `columnList` that has fewer than two, ProseMirror will re-add
197+ * empty columns.
198+ * @param tr The `Transaction` to add the changes to.
199+ * @param columnListPos The position just before the `columnList` node.
200+ */
201+ function removeEmptyColumns ( tr : Transaction , columnListPos : number ) {
202+ const $columnListPos = tr . doc . resolve ( columnListPos ) ;
203+ const columnList = $columnListPos . nodeAfter ;
204+ if ( ! columnList || columnList . type . name !== "columnList" ) {
205+ throw new Error (
206+ "Invalid columnListPos: does not point to columnList node." ,
207+ ) ;
208+ }
209+
210+ for (
211+ let columnIndex = columnList . childCount - 1 ;
212+ columnIndex >= 0 ;
213+ columnIndex --
214+ ) {
215+ const columnPos = tr . doc
216+ . resolve ( $columnListPos . pos + 1 )
217+ . posAtIndex ( columnIndex ) ;
218+ const $columnPos = tr . doc . resolve ( columnPos ) ;
219+ const column = $columnPos . nodeAfter ;
220+ if ( ! column || column . type . name !== "column" ) {
221+ throw new Error ( "Invalid columnPos: does not point to column node." ) ;
222+ }
223+
224+ if ( isEmptyColumn ( column ) ) {
225+ tr . delete ( columnPos , columnPos + column ?. nodeSize ) ;
226+ }
227+ }
228+ }
229+
230+ /**
231+ * Fixes potential issues in a `columnList` node after a
232+ * `blockContainer`/`column` node is (re)moved from it:
233+ *
234+ * - Removes all empty `column` nodes. A `column` node is empty if it has only
235+ * a single empty block.
236+ * - If all but one `column` nodes are empty, replaces the `columnList` with
237+ * the content of the non-empty `column`.
238+ * - If all `column` nodes are empty, removes the `columnList` entirely.
239+ * @param tr The `Transaction` to add the changes to.
240+ * @param columnListPos
241+ * @returns The position just before the `columnList` node.
242+ */
243+ export function fixColumnList ( tr : Transaction , columnListPos : number ) {
244+ removeEmptyColumns ( tr , columnListPos ) ;
245+
246+ const $columnListPos = tr . doc . resolve ( columnListPos ) ;
247+ const columnList = $columnListPos . nodeAfter ;
248+ if ( ! columnList || columnList . type . name !== "columnList" ) {
249+ throw new Error (
250+ "Invalid columnListPos: does not point to columnList node." ,
251+ ) ;
252+ }
253+
254+ if ( columnList . childCount > 2 ) {
255+ return ;
256+ }
257+
258+ const firstColumnBeforePos = columnListPos + 1 ;
259+ const $firstColumnBeforePos = tr . doc . resolve ( firstColumnBeforePos ) ;
260+ const firstColumn = $firstColumnBeforePos . nodeAfter ;
261+ const lastColumnAfterPos = columnListPos + columnList . nodeSize - 1 ;
262+ const $lastColumnAfterPos = tr . doc . resolve ( lastColumnAfterPos ) ;
263+ const lastColumn = $lastColumnAfterPos . nodeBefore ;
264+ if ( ! firstColumn || ! lastColumn ) {
265+ throw new Error ( "Invalid columnList: does not have child node." ) ;
266+ }
267+
268+ const firstColumnEmpty = isEmptyColumn ( firstColumn ) ;
269+ const lastColumnEmpty = isEmptyColumn ( lastColumn ) ;
270+
271+ if ( firstColumnEmpty && lastColumnEmpty ) {
272+ tr . delete ( columnListPos , columnListPos + columnList . nodeSize ) ;
273+
274+ return ;
275+ }
276+
277+ if ( firstColumnEmpty ) {
278+ const lastColumnContent = tr . doc . slice (
279+ lastColumnAfterPos - lastColumn . nodeSize + 1 ,
280+ lastColumnAfterPos - 1 ,
281+ ) ;
282+
283+ tr . delete ( columnListPos , columnListPos + columnList . nodeSize ) ;
284+ tr . insert ( columnListPos , lastColumnContent . content ) ;
285+
286+ return ;
287+ }
288+
289+ if ( lastColumnEmpty ) {
290+ const firstColumnContent = tr . doc . slice (
291+ firstColumnBeforePos + 1 ,
292+ firstColumnBeforePos + firstColumn . nodeSize - 1 ,
293+ ) ;
294+
295+ tr . delete ( columnListPos , columnListPos + columnList . nodeSize ) ;
296+ tr . insert ( columnListPos , firstColumnContent . content ) ;
297+
298+ return ;
299+ }
300+ }
301+
164302export function removeAndInsertBlocks <
165303 BSchema extends BlockSchema ,
166304 I extends InlineContentSchema ,
@@ -222,85 +360,32 @@ export function removeAndInsertBlocks<
222360 const oldDocSize = tr . doc . nodeSize ;
223361
224362 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 (
363+ if (
279364 node . type . name === "column" &&
280365 node . attrs . id !== $pos . nodeAfter ?. attrs . id
281366 ) {
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:
367+ // This is a hacky work around to handle removing all columns in a
368+ // columnList. This is what happens when removing the last 2 columns:
286369 //
287370 // 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.
371+ // 2. `fixColumnList` runs, removing the `columnList` and inserting the
372+ // contents of the last column in its place.
373+ // 3. `removedSize` increases not just by the size of the second-to-last
374+ // `column`, but also by the positions removed due to running
375+ // `fixColumnList`. Some of these positions are after the contents of the
376+ // last `column`, namely just after the `column` and `columnList`.
292377 // 3. `tr.doc.descendants` traverses to the last `column`.
293378 // 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 .
379+ // last `column`. This causes `pos - removedSize` to point to an
380+ // incorrect position, as it expects that the difference in document size
381+ // accounted for by `removedSize` comes before the block being removed .
297382 // 5. The deletion is offset by 3, because of those removed positions
298383 // included in `removedSize` that occur after the last `column`.
299384 //
300385 // Hence why we have to shift the start of the deletion range back by 3.
301386 // 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.
387+ // the size of the second `column`. Since it's been removed, we actually
388+ // care about the size of its children - a difference of 2 positions.
304389 tr . delete ( pos - removedSize + 3 , pos - removedSize + node . nodeSize + 1 ) ;
305390 } else if (
306391 $pos . node ( ) . type . name === "blockGroup" &&
@@ -314,6 +399,13 @@ export function removeAndInsertBlocks<
314399 } else {
315400 tr . delete ( pos - removedSize , pos - removedSize + node . nodeSize ) ;
316401 }
402+
403+ if ( $pos . node ( ) . type . name === "column" ) {
404+ fixColumnList ( tr , $pos . before ( - 1 ) ) ;
405+ } else if ( $pos . node ( ) . type . name === "columnList" ) {
406+ fixColumnList ( tr , $pos . before ( ) ) ;
407+ }
408+
317409 const newDocSize = tr . doc . nodeSize ;
318410 removedSize += oldDocSize - newDocSize ;
319411
0 commit comments