Skip to content

Commit 6469c83

Browse files
committed
refactor(core): simplify the BlockInfo API into the single vocabulary for block plumbing
Replaces the BlockInfo union's isBlockContainer/childContainer/blockContent shape with block/content/children, and annotates it with the facts callers kept re-deriving by hand: contentStart/contentEnd, childrenStart/ childrenEnd, contentKind (read off the spec config stored on the node), and isContentEmpty. The +1/-1 position arithmetic around content edges, tables and child ranges moves into blockEdgePos/blockEdgeSelection/ tableContentCaretPos and the ChildrenInfo fields. The six producers collapse to four named by the input you already have: getBlockInfoFromNode, getBlockInfoAt, getBlockInfoNearPos, getBlockInfoFromSelection. Block navigation (parent/prev/next/last- descendant) joins them here instead of living beside the merge command. All block manipulation (insert/move/nest/replace/split/update, selections, clipboard, serialization, conversions, keyboard shortcuts) is rewired onto the new vocabulary. insertBlocks gains "first-child"/"last-child" placements resolved through getInsertionPos, shared with the move commands so "can this block go here?" has one schema-driven answer; hand-written nodes are checked against their declared content kind when the schema is built (checkNodeMatchesConfig).
1 parent 63c2389 commit 6469c83

46 files changed

Lines changed: 2251 additions & 984 deletions

Some content is hidden

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

‎docs/content/docs/reference/editor/manipulating-content.mdx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,11 +141,11 @@ editor.forEachBlock((block) => {
141141
insertBlocks(
142142
blocksToInsert: PartialBlock[],
143143
referenceBlock: BlockIdentifier,
144-
placement: "before" | "after" = "before"
144+
placement: "before" | "after" | "first-child" | "last-child" = "before"
145145
): void
146146
```
147147

148-
Inserts new blocks relative to an existing block.
148+
Inserts new blocks relative to an existing block. `"before"` and `"after"` make the new blocks siblings of the reference block; `"first-child"` and `"last-child"` nest them inside it.
149149

150150
```typescript
151151
// Insert a paragraph before an existing block
@@ -164,6 +164,13 @@ editor.insertBlocks(
164164
"existing-block-id",
165165
"after",
166166
);
167+
168+
// Insert a paragraph as the last child of an existing block
169+
editor.insertBlocks(
170+
[{ type: "paragraph", content: "Nested paragraph" }],
171+
"existing-block-id",
172+
"last-child",
173+
);
167174
```
168175

169176
### Updating Blocks

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

Lines changed: 151 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Fragment, Slice } from "prosemirror-model";
1+
import { Fragment, Node, NodeType, Slice } from "prosemirror-model";
22
import type { Transaction } from "prosemirror-state";
33
import { ReplaceStep } from "prosemirror-transform";
44
import { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js";
@@ -8,11 +8,122 @@ import {
88
InlineContentSchema,
99
StyleSchema,
1010
} from "../../../../schema/index.js";
11+
import {
12+
BlockInfo,
13+
getBlockInfoFromNode,
14+
} from "../../../getBlockInfoFromPos.js";
1115
import { blockToNode } from "../../../nodeConversions/blockToNode.js";
1216
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
1317
import { getNodeById } from "../../../nodeUtil.js";
1418
import { getPmSchema } from "../../../pmUtil.js";
1519

20+
/**
21+
* Where blocks go relative to a reference block. `"before"`/`"after"` make
22+
* them siblings of it; `"first-child"`/`"last-child"` nest them inside it.
23+
*
24+
* The nested placements also cover blocks that have no children to point at:
25+
* a regular block's `blockGroup` is lazy (`blockContent blockGroup?`), so a
26+
* block without children has no child block to insert before or after.
27+
*/
28+
export type BlockPlacement = "before" | "after" | "first-child" | "last-child";
29+
30+
/**
31+
* Walks one edge of a block's children, descending through nested
32+
* child-holding wrapper blocks (e.g. a Column inside a ColumnList), to the
33+
* deepest position where `nodeType` fits. `edge` picks the trailing edge
34+
* (where a new last child goes) or the leading edge.
35+
*/
36+
function descendToInsertionPos(
37+
info: BlockInfo,
38+
nodeType: NodeType,
39+
edge: "first" | "last",
40+
): { pos: number } | { pos?: undefined; blockedBy: "schema" } {
41+
const children = info.children;
42+
if (!children) {
43+
return { blockedBy: "schema" };
44+
}
45+
46+
const last = edge === "last";
47+
const index = last ? children.node.childCount : 0;
48+
// `canReplaceWith` rather than a bare content match: the children already
49+
// after the position have to still fit once the new node is spliced in.
50+
if (children.node.canReplaceWith(index, index, nodeType)) {
51+
return { pos: last ? children.childrenEnd : children.childrenStart };
52+
}
53+
54+
const child = last ? children.node.lastChild : children.node.firstChild;
55+
if (
56+
!child ||
57+
!(child.type.isInGroup("bnBlock") && child.type.isInGroup("childContainer"))
58+
) {
59+
return { blockedBy: "schema" };
60+
}
61+
return descendToInsertionPos(
62+
getBlockInfoFromNode(
63+
child,
64+
last ? children.childrenEnd - child.nodeSize : children.childrenStart,
65+
),
66+
nodeType,
67+
edge,
68+
);
69+
}
70+
71+
/**
72+
* Resolves a `placement` against a reference block into the document position
73+
* a node of `nodeType` should be inserted at, or `null` when the reference
74+
* block cannot take it there.
75+
*
76+
* Shared by `insertBlocks` and the move commands, so "does this block fit
77+
* here?" is answered in one place. The answer comes from the schema's content
78+
* matches rather than from a hand-written rule.
79+
*
80+
* `wrapIn` is set when the position only becomes valid once the nodes are
81+
* wrapped: a regular block with no children yet has no `blockGroup` for them
82+
* to go in, so one is created around them.
83+
*/
84+
export function getInsertionPos(
85+
doc: Node,
86+
reference: { node: Node; posBeforeNode: number },
87+
placement: BlockPlacement,
88+
nodeType: NodeType,
89+
): { pos: number; wrapIn?: NodeType } | null {
90+
const { node, posBeforeNode } = reference;
91+
92+
if (placement === "before" || placement === "after") {
93+
const pos =
94+
placement === "before" ? posBeforeNode : posBeforeNode + node.nodeSize;
95+
const $pos = doc.resolve(pos);
96+
97+
// `canReplaceWith` rather than a bare content match: the nodes already
98+
// after the position have to still fit once the new one is spliced in.
99+
return $pos.parent.canReplaceWith($pos.index(), $pos.index(), nodeType)
100+
? { pos }
101+
: null;
102+
}
103+
104+
const info = getBlockInfoFromNode(node, posBeforeNode);
105+
106+
if (info.children) {
107+
const { pos } = descendToInsertionPos(
108+
info,
109+
nodeType,
110+
placement === "first-child" ? "first" : "last",
111+
);
112+
113+
return pos === undefined ? null : { pos };
114+
}
115+
116+
// No children holder implies a `blockContainer` with no children yet: its
117+
// `blockGroup` is lazy (`blockContent blockGroup?`), so the position after
118+
// the content node only becomes valid once the nodes are wrapped in a new
119+
// group.
120+
const blockGroupType = nodeType.schema.nodes["blockGroup"];
121+
122+
return info.hasContent && blockGroupType?.contentMatch.matchType(nodeType)
123+
? { pos: info.content.afterPos, wrapIn: blockGroupType }
124+
: null;
125+
}
126+
16127
export function insertBlocks<
17128
BSchema extends BlockSchema,
18129
I extends InlineContentSchema,
@@ -21,7 +132,7 @@ export function insertBlocks<
21132
tr: Transaction,
22133
blocksToInsert: PartialBlock<BSchema, I, S>[],
23134
referenceBlock: BlockIdentifier,
24-
placement: "before" | "after" = "before",
135+
placement: BlockPlacement = "before",
25136
): Block<BSchema, I, S>[] {
26137
const id =
27138
typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id;
@@ -37,14 +148,47 @@ export function insertBlocks<
37148
throw new Error(`Block with ID ${id} not found`);
38149
}
39150

40-
let pos = posInfo.posBeforeNode;
41-
if (placement === "after") {
42-
pos += posInfo.node.nodeSize;
151+
if (nodesToInsert.length === 0) {
152+
return [];
43153
}
44154

45-
tr.step(
46-
new ReplaceStep(pos, pos, new Slice(Fragment.from(nodesToInsert), 0, 0)),
155+
const target = getInsertionPos(
156+
tr.doc,
157+
posInfo,
158+
placement,
159+
nodesToInsert[0].type,
47160
);
161+
if (!target) {
162+
throw new Error(
163+
`Cannot insert blocks at "${placement}" of block "${id}": no valid position for them`,
164+
);
165+
}
166+
167+
// `getInsertionPos` can only answer for the first node's type: the fragment
168+
// doesn't exist yet when it runs. The whole fragment still has to fit, so it
169+
// is checked here, where the nodes are known, rather than left to `tr.step`
170+
// to reject with a ProseMirror-level message.
171+
if (
172+
target.wrapIn &&
173+
!target.wrapIn.validContent(Fragment.from(nodesToInsert))
174+
) {
175+
throw new Error(
176+
`Cannot insert blocks at "${placement}" of block "${id}": a "${target.wrapIn.name}" doesn't accept them`,
177+
);
178+
}
179+
180+
const fragment = target.wrapIn
181+
? Fragment.from(target.wrapIn.create(null, nodesToInsert))
182+
: Fragment.from(nodesToInsert);
183+
184+
const $target = tr.doc.resolve(target.pos);
185+
if (!$target.parent.canReplace($target.index(), $target.index(), fragment)) {
186+
throw new Error(
187+
`Cannot insert blocks at "${placement}" of block "${id}": a "${$target.parent.type.name}" doesn't accept them`,
188+
);
189+
}
190+
191+
tr.step(new ReplaceStep(target.pos, target.pos, new Slice(fragment, 0, 0)));
48192

49193
// Now that the `PartialBlock`s have been converted to nodes, we can
50194
// re-convert them into full `Block`s.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// @vitest-environment node
2+
import {
3+
afterAll,
4+
beforeAll,
5+
beforeEach,
6+
describe,
7+
expect,
8+
it,
9+
} from "vite-plus/test";
10+
11+
import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
12+
13+
let editor: BlockNoteEditor<any, any, any>;
14+
15+
beforeAll(() => {
16+
editor = BlockNoteEditor.create() as any;
17+
});
18+
19+
afterAll(() => {
20+
editor._tiptapEditor.destroy();
21+
editor = undefined as any;
22+
});
23+
24+
beforeEach(() => {
25+
editor.replaceBlocks(editor.document, [
26+
{ id: "p-0", type: "paragraph", content: "Paragraph 0" },
27+
]);
28+
});
29+
30+
describe('insertBlocks "first-child" / "last-child"', () => {
31+
it("nests under a childless block, creating the blockGroup", () => {
32+
expect(editor.getBlock("p-0")!.children).toHaveLength(0);
33+
34+
editor.insertBlocks(
35+
[{ id: "first", type: "paragraph" }],
36+
"p-0",
37+
"first-child",
38+
);
39+
editor.insertBlocks(
40+
[{ id: "last", type: "paragraph" }],
41+
"p-0",
42+
"last-child",
43+
);
44+
45+
expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([
46+
"first",
47+
"last",
48+
]);
49+
});
50+
51+
it("prepends and appends around existing children", () => {
52+
editor.replaceBlocks(editor.document, [
53+
{
54+
id: "p-0",
55+
type: "paragraph",
56+
content: "Paragraph 0",
57+
children: [{ id: "existing", type: "paragraph", content: "Existing" }],
58+
},
59+
]);
60+
61+
editor.insertBlocks(
62+
[{ id: "first", type: "paragraph" }],
63+
"p-0",
64+
"first-child",
65+
);
66+
editor.insertBlocks(
67+
[{ id: "last", type: "paragraph" }],
68+
"p-0",
69+
"last-child",
70+
);
71+
72+
expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([
73+
"first",
74+
"existing",
75+
"last",
76+
]);
77+
});
78+
79+
it("still inserts siblings with the default and explicit placements", () => {
80+
editor.insertBlocks([{ id: "after", type: "paragraph" }], "p-0");
81+
editor.insertBlocks([{ id: "before", type: "paragraph" }], "p-0", "before");
82+
editor.insertBlocks([{ id: "sibling", type: "paragraph" }], "p-0", "after");
83+
84+
expect(editor.document.map((block) => block.id)).toEqual([
85+
"after",
86+
"before",
87+
"p-0",
88+
"sibling",
89+
]);
90+
});
91+
92+
it("still inserts a batch that fits in full", () => {
93+
editor.insertBlocks(
94+
[
95+
{ id: "one", type: "paragraph" },
96+
{ id: "two", type: "paragraph" },
97+
],
98+
"p-0",
99+
"last-child",
100+
);
101+
102+
expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([
103+
"one",
104+
"two",
105+
]);
106+
});
107+
108+
it("throws when the reference block does not exist", () => {
109+
expect(() =>
110+
editor.insertBlocks([{ type: "paragraph" }], "missing-id", "last-child"),
111+
).toThrow(/Block with ID missing-id not found/);
112+
});
113+
});

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vite-plus/test";
22

33
import { getBlockInfoFromSelection } from "../../../getBlockInfoFromPos.js";
44
import { setupTestEnv } from "../../setupTestEnv.js";
5-
import { getParentBlockInfo, mergeBlocksCommand } from "./mergeBlocks.js";
5+
import { getParentBlockInfo } from "../../../getBlockInfoFromPos.js";
6+
import { mergeBlocksCommand } from "./mergeBlocks.js";
67

78
const getEditor = setupTestEnv();
89

@@ -14,7 +15,7 @@ function mergeBlocks(posBetweenBlocks: number) {
1415

1516
function getPosBeforeSelectedBlock() {
1617
return getEditor().transact(
17-
(tr) => getBlockInfoFromSelection(tr).bnBlock.beforePos,
18+
(tr) => getBlockInfoFromSelection(tr).block.beforePos,
1819
);
1920
}
2021

0 commit comments

Comments
 (0)