Skip to content

Commit 445e213

Browse files
committed
refactor(core): consolidate block positioning and selection helpers
1 parent ce11d6f commit 445e213

45 files changed

Lines changed: 1654 additions & 2783 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,7 @@ import { blockToNode } from "../../../nodeConversions/blockToNode.js";
1313
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
1414
import { getNodeById } from "../../../nodeUtil.js";
1515
import { getPmSchema } from "../../../pmUtil.js";
16-
import {
17-
descendToFirstInsertionPos,
18-
descendToLastInsertionPos,
19-
} from "../../containers/containerNav.js";
16+
import { descendToInsertionPos } from "../../containers/containerNav.js";
2017

2118
/**
2219
* Where blocks go relative to a reference block. `"before"`/`"after"` make
@@ -63,13 +60,14 @@ export function getInsertionPos(
6360
const info = getBlockInfoFromNode(node, posBeforeNode);
6461

6562
if (info.children) {
66-
// The descent helpers can stop at sealed boundaries but this caller lets
67-
// them cross: an explicit `insertBlocks` placement is an intentional
63+
// The descent helper can stop at sealed boundaries but this caller lets
64+
// it cross: an explicit `insertBlocks` placement is an intentional
6865
// crossing.
69-
const pos =
70-
placement === "first-child"
71-
? descendToFirstInsertionPos(info, nodeType)
72-
: descendToLastInsertionPos(info, nodeType);
66+
const pos = descendToInsertionPos(
67+
info,
68+
nodeType,
69+
placement === "first-child" ? "first" : "last",
70+
);
7371

7472
return pos === null ? null : { pos };
7573
}

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

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -49,21 +49,28 @@ export const mergeBlocksCommand =
4949
return false;
5050
}
5151

52-
// Removes a level of nesting all children of the next block by 1 level, if
53-
// it contains both content and block group nodes.
54-
if (nextBlockInfo.children) {
55-
const childBlocksStart = state.doc.resolve(
56-
nextBlockInfo.children.childrenStart,
57-
);
58-
const childBlocksEnd = state.doc.resolve(
59-
nextBlockInfo.children.childrenEnd,
60-
);
61-
const childBlocksRange = childBlocksStart.blockRange(childBlocksEnd);
52+
// Un-nests the next block's children by one level, so they survive as
53+
// siblings of the merged block rather than as children of a block that no
54+
// longer exists once the boundary below is deleted.
55+
//
56+
// Note `state.tr` is tiptap's chainable state, whose getter returns the one
57+
// transaction shared by the command chain (not a fresh `Transaction` like
58+
// `EditorState.tr`), so this lift carries over into the `dispatch` below.
59+
if (dispatch && nextBlockInfo.children) {
60+
const childBlocksRange = state.doc
61+
.resolve(nextBlockInfo.children.childrenStart)
62+
.blockRange(state.doc.resolve(nextBlockInfo.children.childrenEnd));
6263

63-
if (dispatch) {
64-
const pos = state.doc.resolve(nextBlockInfo.block.beforePos);
65-
state.tr.lift(childBlocksRange!, pos.depth);
64+
if (!childBlocksRange) {
65+
throw new Error(
66+
"Children of a block are expected to form a block range",
67+
);
6668
}
69+
70+
state.tr.lift(
71+
childBlocksRange,
72+
state.doc.resolve(nextBlockInfo.block.beforePos).depth,
73+
);
6774
}
6875

6976
// Deletes the boundary between the two blocks. Can be thought of as

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

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Schema } from "prosemirror-model";
12
import {
23
NodeSelection,
34
Selection,
@@ -9,15 +10,43 @@ import { CellSelection } from "prosemirror-tables";
910
import { Block } from "../../../../blocks/defaultBlocks.js";
1011
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor";
1112
import { BlockIdentifier } from "../../../../schema/index.js";
13+
import {
14+
isBlockGroupInsertable,
15+
isContainerNode,
16+
} from "../../../../schema/blocks/children.js";
1217
import {
1318
getBlockInfoNearPos,
1419
getNodeId,
1520
} from "../../../getBlockInfoFromPos.js";
1621
import { getNodeById } from "../../../nodeUtil.js";
17-
import { flattenNonInsertableBlocks } from "../../containers/fixContainer.js";
1822
import { getInsertionPos, insertBlocks } from "../insertBlocks/insertBlocks.js";
1923
import { removeAndInsertBlocks } from "../replaceBlocks/replaceBlocks.js";
2024

25+
/**
26+
* Dissolves `placement: "containerOnly"` blocks into their children.
27+
*
28+
* A `containerOnly` block (a `column`, say) is defined only in terms of the
29+
* container that holds it, so it can't land anywhere a regular block goes —
30+
* moving one out of its container moves its children instead. Every other
31+
* block passes through as itself.
32+
*/
33+
function dissolveContainerOnlyBlocks(
34+
blocks: Block<any, any, any>[],
35+
pmSchema: Schema,
36+
): Block<any, any, any>[] {
37+
return blocks.flatMap((block) => {
38+
const nodeType = pmSchema.nodes[block.type];
39+
// A container denied the `blockGroupChild` group is one declared
40+
// `placement: "containerOnly"`.
41+
const isContainerOnly =
42+
isContainerNode(nodeType) && !isBlockGroupInsertable(nodeType);
43+
44+
return isContainerOnly
45+
? dissolveContainerOnlyBlocks(block.children, pmSchema)
46+
: [block];
47+
});
48+
}
49+
2150
type BlockSelectionData = (
2251
| {
2352
type: "text";
@@ -162,9 +191,7 @@ export function moveBlocks(
162191
removeAndInsertBlocks(tr, blocks, [], { fixContainers: false });
163192
insertBlocks<any, any, any>(
164193
tr,
165-
// Blocks that can't stand on their own outside their container (e.g. a
166-
// `column` outside its `columnList`) are replaced by their children.
167-
flattenNonInsertableBlocks(blocks, editor.pmSchema),
194+
dissolveContainerOnlyBlocks(blocks, editor.pmSchema),
168195
referenceBlock,
169196
placement,
170197
);
@@ -213,17 +240,16 @@ function checkPlacementIsValid(
213240
placement: "before" | "after",
214241
movedBlock: Block<any, any, any>,
215242
): boolean {
216-
// The PM node type to validate the destination against: the first flattened
217-
// block's own node type when it's a container (e.g. `callout`), otherwise
218-
// the generic `blockContainer` wrapper. Mirrors what `moveBlocks` inserts
219-
// (`flattenNonInsertableBlocks` + `insertBlocks`), so the placement
220-
// pre-check agrees with the insertion instead of always assuming a regular
221-
// block.
222-
const first = flattenNonInsertableBlocks([movedBlock], editor.pmSchema)[0];
223-
const firstType = first?.type ? editor.pmSchema.nodes[first.type] : undefined;
224-
const nodeType = firstType?.isInGroup("bnBlock")
225-
? firstType
226-
: editor.pmSchema.nodes["blockContainer"];
243+
// The PM node type to validate the destination against: the first block
244+
// `moveBlocks` would actually insert, which is `movedBlock` itself unless it
245+
// dissolves. A container (e.g. a `callout`) is inserted as its own node
246+
// type; anything else goes in as a generic `blockContainer` wrapper.
247+
const first = dissolveContainerOnlyBlocks([movedBlock], editor.pmSchema)[0];
248+
const firstType = first ? editor.pmSchema.nodes[first.type] : undefined;
249+
const nodeType =
250+
firstType && isContainerNode(firstType)
251+
? firstType
252+
: editor.pmSchema.nodes["blockContainer"];
227253

228254
return editor.transact((tr) => {
229255
const posInfo = getNodeById(referenceBlock.id, tr.doc);

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

Lines changed: 21 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { Transaction } from "prosemirror-state";
33
import { canJoin, liftTarget, ReplaceAroundStep } from "prosemirror-transform";
44

55
import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
6-
import { getBlockInfoFromSelection } from "../../../getBlockInfoFromPos.js";
76

87
/**
98
* Modified version of prosemirror-schema-list's sinkItem.
@@ -62,14 +61,17 @@ function sinkItem(tr: Transaction, itemType: NodeType, groupType: NodeType) {
6261
return true;
6362
}
6463

65-
export function nestBlock(editor: BlockNoteEditor<any, any, any>) {
66-
return editor.transact((tr) => {
67-
return sinkItem(
64+
function nestCommand(editor: BlockNoteEditor<any, any, any>) {
65+
return (tr: Transaction) =>
66+
sinkItem(
6867
tr,
6968
editor.pmSchema.nodes["blockContainer"],
7069
editor.pmSchema.nodes["blockGroup"],
7170
);
72-
});
71+
}
72+
73+
export function nestBlock(editor: BlockNoteEditor<any, any, any>) {
74+
return editor.transact(nestCommand(editor));
7375
}
7476

7577
/**
@@ -177,50 +179,28 @@ export function liftItem(
177179
return false;
178180
}
179181

180-
export function unnestBlock(editor: BlockNoteEditor<any, any, any>) {
181-
return editor.transact((tr) =>
182+
function unnestCommand(editor: BlockNoteEditor<any, any, any>) {
183+
return (tr: Transaction) =>
182184
liftItem(
183185
tr,
184186
editor.pmSchema.nodes["blockContainer"],
185187
editor.pmSchema.nodes["blockGroup"],
186-
),
187-
);
188+
);
188189
}
189190

191+
export function unnestBlock(editor: BlockNoteEditor<any, any, any>) {
192+
return editor.transact(unnestCommand(editor));
193+
}
194+
195+
// `canExec` hands the command a transaction it never dispatches, so "can I
196+
// nest?" is answered by nesting and throwing the result away. A second
197+
// statement of the preconditions would drift from the command it describes —
198+
// and did: it read a previous sibling's mere existence, so a container block
199+
// before the cursor enabled the button while `nestBlock` did nothing.
190200
export function canNestBlock(editor: BlockNoteEditor<any, any, any>) {
191-
return editor.transact((tr) => {
192-
const { block: blockContainer } = getBlockInfoFromSelection(tr);
193-
194-
// Mirrors `sinkItem`'s precondition: nesting is only possible under a
195-
// previous sibling that is itself a `blockContainer`. (A previous sibling
196-
// of another type, e.g. a container block, made this return true while
197-
// `nestBlock` did nothing.)
198-
return (
199-
tr.doc.resolve(blockContainer.beforePos).nodeBefore?.type ===
200-
editor.pmSchema.nodes["blockContainer"]
201-
);
202-
});
201+
return editor.canExec((state) => nestCommand(editor)(state.tr));
203202
}
204203

205204
export function canUnnestBlock(editor: BlockNoteEditor<any, any, any>) {
206-
return editor.transact((tr) => {
207-
const { $from, $to } = tr.selection;
208-
209-
// Mirrors `liftItem`'s preconditions instead of approximating with depth.
210-
// A block whose depth > 1 because it sits inside a container (e.g. a
211-
// column) is not un-nestable, only a block nested under another
212-
// `blockContainer` is.
213-
const range = $from.blockRange(
214-
$to,
215-
(node) => node.childCount > 0 && node.type.isInGroup("childContainer"),
216-
);
217-
if (!range) {
218-
return false;
219-
}
220-
221-
return (
222-
$from.node(range.depth - 1).type ===
223-
editor.pmSchema.nodes["blockContainer"]
224-
);
225-
});
205+
return editor.canExec((state) => unnestCommand(editor)(state.tr));
226206
}

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

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,12 @@ export function removeAndInsertBlocks<
5353
typeof blocksToRemove[0] === "string"
5454
? blocksToRemove[0]
5555
: blocksToRemove[0].id;
56-
let removedSize = 0;
56+
57+
// The walk below reads the document as it is now, but mutates it as it
58+
// goes, so its positions go stale. `tr.mapping` already tracks exactly
59+
// that; sliced from here so it ignores steps the caller added earlier.
60+
const stepsBefore = tr.steps.length;
61+
const mapPos = (pos: number) => tr.mapping.slice(stepsBefore).map(pos);
5762

5863
tr.doc.descendants((node, pos) => {
5964
// Skips traversing nodes after all target blocks have been removed.
@@ -77,16 +82,10 @@ export function removeAndInsertBlocks<
7782
idsOfBlocksToRemove.delete(nodeId);
7883

7984
if (blocksToInsert.length > 0 && nodeId === idOfFirstBlock) {
80-
const oldDocSize = tr.doc.nodeSize;
81-
tr.insert(pos, nodesToInsert);
82-
const newDocSize = tr.doc.nodeSize;
83-
84-
removedSize += oldDocSize - newDocSize;
85+
tr.insert(mapPos(pos), nodesToInsert);
8586
}
8687

87-
const oldDocSize = tr.doc.nodeSize;
88-
89-
const $pos = tr.doc.resolve(pos - removedSize);
88+
const $pos = tr.doc.resolve(mapPos(pos));
9089

9190
for (const container of getAncestorContainers($pos.doc, $pos.pos)) {
9291
if (!containersToFix.some((c) => c.id === container.id)) {
@@ -112,9 +111,6 @@ export function removeAndInsertBlocks<
112111
tr.delete($pos.pos, $pos.pos + node.nodeSize);
113112
}
114113

115-
const newDocSize = tr.doc.nodeSize;
116-
removedSize += oldDocSize - newDocSize;
117-
118114
return false;
119115
});
120116

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

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -106,28 +106,11 @@ export function updateBlockTr<
106106
? replaceToPos - blockInfo.contentStart
107107
: undefined;
108108

109-
if (
110-
blockInfo.hasContent &&
111-
blockInfo.block.node.type.name === "blockContainer" &&
112-
newNodeType.isInGroup("blockContent")
113-
) {
114-
updateChildren(block, tr, blockInfo);
115-
// The code below determines the new content of the block.
116-
// or "keep" to keep as-is
117-
updateBlockContentNode(
118-
block,
119-
tr,
120-
pmSchema.nodes[blockInfo.blockNoteType],
121-
newNodeType,
122-
blockInfo,
123-
replaceFromOffset,
124-
replaceToOffset,
125-
);
126-
} else if (!blockInfo.hasContent && newNodeType.isInGroup("bnBlock")) {
127-
updateChildren(block, tr, blockInfo);
128-
// old node was a block type (like column or columnList) and new block as well
129-
// No op, we just update the block below (at end of function) and have already updated the children
130-
} else {
109+
// `hasContent` is exactly `blockContainer`-ness, and a block type resolves
110+
// to either a `blockContent` node (a regular block) or a `bnBlock` one (a
111+
// container), so the two together say whether the update keeps the block's
112+
// shape. Only a same-shape update can happen in place.
113+
if (blockInfo.hasContent !== newNodeType.isInGroup("blockContent")) {
131114
// switching from blockContainer to non-blockContainer or v.v.
132115
// currently breaking for column slash menu items converting empty block
133116
// to column.
@@ -165,6 +148,22 @@ export function updateBlockTr<
165148
return;
166149
}
167150

151+
updateChildren(block, tr, blockInfo);
152+
153+
if (blockInfo.hasContent) {
154+
// The code below determines the new content of the block.
155+
// or "keep" to keep as-is
156+
updateBlockContentNode(
157+
block,
158+
tr,
159+
pmSchema.nodes[blockInfo.blockNoteType],
160+
newNodeType,
161+
blockInfo,
162+
replaceFromOffset,
163+
replaceToOffset,
164+
);
165+
}
166+
168167
// Adds all provided props as attributes to the parent blockContainer node too, and also preserves existing
169168

170169
// attributes. Uses minimal steps so that an unchanged container (e.g. when

0 commit comments

Comments
 (0)