Skip to content

Commit 5ef97ba

Browse files
nperez0111YousefED
andauthored
refactor(core): simplify the BlockInfo API into a single vocabulary for block plumbing (#3051)
* 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). * refactor(core): consolidate BlockInfo shape and insertion helpers Resolve block shape directly, validate wrapper structure at the BlockInfo boundary, and convert content from the established content kind. Move insertion resolution into BlockInfo and use node bounds for last-descendant navigation, updating callers and regression coverage. --------- Co-authored-by: yousefed <yousefdardiry@gmail.com>
1 parent be20d8b commit 5ef97ba

49 files changed

Lines changed: 2275 additions & 1062 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: 10 additions & 3 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"
145-
): void
144+
placement: "before" | "after" | "first-child" | "last-child" = "before"
145+
): Block[]
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. Returns the inserted blocks.
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: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import {
88
InlineContentSchema,
99
StyleSchema,
1010
} from "../../../../schema/index.js";
11+
import {
12+
type BlockPlacement,
13+
getInsertionPos,
14+
getBlockInfoAt,
15+
} from "../../../getBlockInfoFromPos.js";
1116
import { blockToNode } from "../../../nodeConversions/blockToNode.js";
1217
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
1318
import { getNodeById } from "../../../nodeUtil.js";
@@ -21,7 +26,7 @@ export function insertBlocks<
2126
tr: Transaction,
2227
blocksToInsert: PartialBlock<BSchema, I, S>[],
2328
referenceBlock: BlockIdentifier,
24-
placement: "before" | "after" = "before",
29+
placement: BlockPlacement = "before",
2530
): Block<BSchema, I, S>[] {
2631
const id =
2732
typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id;
@@ -37,14 +42,47 @@ export function insertBlocks<
3742
throw new Error(`Block with ID ${id} not found`);
3843
}
3944

40-
let pos = posInfo.posBeforeNode;
41-
if (placement === "after") {
42-
pos += posInfo.node.nodeSize;
45+
if (nodesToInsert.length === 0) {
46+
return [];
4347
}
4448

45-
tr.step(
46-
new ReplaceStep(pos, pos, new Slice(Fragment.from(nodesToInsert), 0, 0)),
49+
const target = getInsertionPos(
50+
tr.doc,
51+
getBlockInfoAt(tr.doc, posInfo.posBeforeNode),
52+
placement,
53+
nodesToInsert[0].type,
4754
);
55+
if (!target) {
56+
throw new Error(
57+
`Cannot insert blocks at "${placement}" of block "${id}": no valid position for them`,
58+
);
59+
}
60+
61+
// `getInsertionPos` can only answer for the first node's type: the fragment
62+
// doesn't exist yet when it runs. The whole fragment still has to fit, so it
63+
// is checked here, where the nodes are known, rather than left to `tr.step`
64+
// to reject with a ProseMirror-level message.
65+
if (
66+
target.wrapIn &&
67+
!target.wrapIn.validContent(Fragment.from(nodesToInsert))
68+
) {
69+
throw new Error(
70+
`Cannot insert blocks at "${placement}" of block "${id}": a "${target.wrapIn.name}" doesn't accept them`,
71+
);
72+
}
73+
74+
const fragment = target.wrapIn
75+
? Fragment.from(target.wrapIn.create(null, nodesToInsert))
76+
: Fragment.from(nodesToInsert);
77+
78+
const $target = tr.doc.resolve(target.pos);
79+
if (!$target.parent.canReplace($target.index(), $target.index(), fragment)) {
80+
throw new Error(
81+
`Cannot insert blocks at "${placement}" of block "${id}": a "${$target.parent.type.name}" doesn't accept them`,
82+
);
83+
}
84+
85+
tr.step(new ReplaceStep(target.pos, target.pos, new Slice(fragment, 0, 0)));
4886

4987
// Now that the `PartialBlock`s have been converted to nodes, we can
5088
// 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)