Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ editor.insertBlocks([{ type: "paragraph" }], calloutId, "before");
editor.insertBlocks([{ type: "paragraph" }], calloutId, "after");

// Nested inside it, as its first or last child:
editor.insertBlocks([{ type: "paragraph" }], calloutId, "start");
editor.insertBlocks([{ type: "paragraph" }], calloutId, "end");
editor.insertBlocks([{ type: "paragraph" }], calloutId, "first-child");
editor.insertBlocks([{ type: "paragraph" }], calloutId, "last-child");
```

The nested placements are what addresses a container with no children to point at. A `min: 0` container that is currently empty has no child block to insert before or after. Whether a block fits is answered by the schema, so it's your `children` config that decides.
Expand Down
6 changes: 3 additions & 3 deletions docs/content/docs/reference/editor/manipulating-content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,11 @@ editor.forEachBlock((block) => {
insertBlocks(
blocksToInsert: PartialBlock[],
referenceBlock: BlockIdentifier,
placement: "before" | "after" | "start" | "end" = "before"
placement: "before" | "after" | "first-child" | "last-child" = "before"
): void
```

Inserts new blocks relative to an existing block. `"before"` and `"after"` make the new blocks siblings of the reference block; `"start"` and `"end"` nest them inside it, as its first or last children. See [Inserting into a container](/docs/features/custom-schemas/container-blocks#inserting-into-a-container).
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. See [Inserting into a container](/docs/features/custom-schemas/container-blocks#inserting-into-a-container).

```typescript
// Insert a paragraph before an existing block
Expand All @@ -169,7 +169,7 @@ editor.insertBlocks(
editor.insertBlocks(
[{ type: "paragraph", content: "Nested paragraph" }],
"container-block-id",
"end",
"last-child",
);
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
InlineContentSchema,
StyleSchema,
} from "../../../../schema/index.js";
import { isContainerNode } from "../../../../schema/blocks/children.js";
import { getBlockInfoFromNode } from "../../../getBlockInfoFromPos.js";
import { blockToNode } from "../../../nodeConversions/blockToNode.js";
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
import { getNodeById } from "../../../nodeUtil.js";
Expand All @@ -19,15 +19,14 @@ import {
} from "../../containers/containerNav.js";

/**
* Where blocks go relative to a reference block. `"before"`/`"after"` make them
* siblings of it; `"start"`/`"end"` nest them inside it, as its first or last
* children.
* Where blocks go relative to a reference block. `"before"`/`"after"` make
* them siblings of it; `"first-child"`/`"last-child"` nest them inside it.
*
* The nested placements cover containers that have no children to point at:
* a `min: 0` container that is currently empty has no child block to insert
* before or after.
*/
export type BlockPlacement = "before" | "after" | "start" | "end";
export type BlockPlacement = "before" | "after" | "first-child" | "last-child";

/**
* Resolves a `placement` against a reference block into the document position
Expand All @@ -51,11 +50,6 @@ export function getInsertionPos(
): { pos: number; wrapIn?: NodeType } | null {
const { node, posBeforeNode } = reference;

const descend = (holder: Node, pos: number) =>
placement === "start"
? descendToFirstInsertionPos(holder, pos, nodeType)
: descendToLastInsertionPos(holder, pos, nodeType);

if (placement === "before" || placement === "after") {
const pos =
placement === "before" ? posBeforeNode : posBeforeNode + node.nodeSize;
Expand All @@ -66,33 +60,29 @@ export function getInsertionPos(
: null;
}

// A container holds its children itself. The descent helpers ignore sealed
// boundaries by default, which is correct here: an explicit `insertBlocks`
// placement is an intentional crossing.
if (isContainerNode(node.type)) {
const pos = descend(node, posBeforeNode);
const info = getBlockInfoFromNode(node, posBeforeNode);

if (info.children) {
// The descent helpers can stop at sealed boundaries but this caller lets
// them cross: an explicit `insertBlocks` placement is an intentional
// crossing.
const pos =
placement === "first-child"
? descendToFirstInsertionPos(info, nodeType)
: descendToLastInsertionPos(info, nodeType);

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

// A regular block keeps its children in a `blockGroup` that only exists once
// it has some.
// No children holder implies a `blockContainer` with no children yet
// (containers always have one): its `blockGroup` is lazy (`blockContent
// blockGroup?`), so the position after the content node only becomes valid
// once the nodes are wrapped in a new group.
const blockGroupType = nodeType.schema.nodes["blockGroup"];
if (node.type.name !== "blockContainer" || !blockGroupType) {
return null;
}

const blockGroupPos = posBeforeNode + 1 + node.firstChild!.nodeSize;

if (node.childCount < 2) {
return blockGroupType.contentMatch.matchType(nodeType)
? { pos: blockGroupPos, wrapIn: blockGroupType }
: null;
}

const pos = descend(node.lastChild!, blockGroupPos);

return pos === null ? null : { pos };
return info.hasContent && blockGroupType?.contentMatch.matchType(nodeType)
? { pos: info.content.afterPos, wrapIn: blockGroupType }
: null;
}

export function insertBlocks<
Expand Down Expand Up @@ -134,7 +124,7 @@ export function insertBlocks<
`Cannot insert a block of type "${blocksToInsert[0].type ?? "paragraph"}" ` +
(placement === "before" || placement === "after"
? `${placement} block with ID ${id}: its parent does not accept it.`
: `at the ${placement} of block with ID ${id}: the block does not accept it as a child.`),
: `as the ${placement} of block with ID ${id}: the block does not accept it as a child.`),
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ const container = (type: string, config: Record<string, unknown>) =>
const schema = BlockNoteSchema.create().extend({
blockSpecs: {
...defaultBlockSpecs,
// Why `"start"`/`"end"` exist: a container that may legally hold nothing
// has no child block to address, so `"before"`/`"after"` cannot reach
// inside it.
// Why `"first-child"`/`"last-child"` exist: a container that may legally
// hold nothing has no child block to address, so `"before"`/`"after"`
// cannot reach inside it.
box: container("box", {
content: "none",
children: { allow: "any", min: 0 },
Expand Down Expand Up @@ -68,16 +68,24 @@ beforeEach(() => {
]);
});

describe('insertBlocks "start" / "end"', () => {
describe('insertBlocks "first-child" / "last-child"', () => {
it("inserts into a childless container", () => {
editor.replaceBlocks(editor.document, [
{ id: "b-0", type: "box" },
{ id: "trailing", type: "paragraph", content: "" },
]);
expect(editor.getBlock("b-0")!.children).toHaveLength(0);

editor.insertBlocks([{ id: "first", type: "paragraph" }], "b-0", "start");
editor.insertBlocks([{ id: "last", type: "paragraph" }], "b-0", "end");
editor.insertBlocks(
[{ id: "first", type: "paragraph" }],
"b-0",
"first-child",
);
editor.insertBlocks(
[{ id: "last", type: "paragraph" }],
"b-0",
"last-child",
);

expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([
"first",
Expand All @@ -95,8 +103,16 @@ describe('insertBlocks "start" / "end"', () => {
{ id: "trailing", type: "paragraph", content: "" },
]);

editor.insertBlocks([{ id: "first", type: "paragraph" }], "b-0", "start");
editor.insertBlocks([{ id: "last", type: "paragraph" }], "b-0", "end");
editor.insertBlocks(
[{ id: "first", type: "paragraph" }],
"b-0",
"first-child",
);
editor.insertBlocks(
[{ id: "last", type: "paragraph" }],
"b-0",
"last-child",
);

expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([
"first",
Expand All @@ -120,8 +136,16 @@ describe('insertBlocks "start" / "end"', () => {

// `grid` itself only accepts `cell`s, so both placements have to find the
// leading/trailing cell rather than giving up.
editor.insertBlocks([{ id: "first", type: "paragraph" }], "g-0", "start");
editor.insertBlocks([{ id: "last", type: "paragraph" }], "g-0", "end");
editor.insertBlocks(
[{ id: "first", type: "paragraph" }],
"g-0",
"first-child",
);
editor.insertBlocks(
[{ id: "last", type: "paragraph" }],
"g-0",
"last-child",
);

const grid = editor.getBlock("g-0")!;
expect(grid.children[0].children.map((child: any) => child.id)).toContain(
Expand All @@ -137,8 +161,16 @@ describe('insertBlocks "start" / "end"', () => {
{ id: "p-0", type: "paragraph", content: "Paragraph 0" },
]);

editor.insertBlocks([{ id: "existing", type: "paragraph" }], "p-0", "end");
editor.insertBlocks([{ id: "first", type: "paragraph" }], "p-0", "start");
editor.insertBlocks(
[{ id: "existing", type: "paragraph" }],
"p-0",
"last-child",
);
editor.insertBlocks(
[{ id: "first", type: "paragraph" }],
"p-0",
"first-child",
);

expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([
"first",
Expand All @@ -157,7 +189,7 @@ describe('insertBlocks "start" / "end"', () => {
]);

expect(() =>
editor.insertBlocks([{ type: "paragraph" }], "s-0", "end"),
editor.insertBlocks([{ type: "paragraph" }], "s-0", "last-child"),
).toThrow(/does not accept it as a child/);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { describe, expect, it } from "vite-plus/test";

import { getBlockInfoFromSelection } from "../../../getBlockInfoFromPos.js";
import { setupTestEnv } from "../../setupTestEnv.js";
import { getParentBlockInfo, mergeBlocksCommand } from "./mergeBlocks.js";
import { getParentBlockInfo } from "../../../getBlockInfoFromPos.js";
import { mergeBlocksCommand } from "./mergeBlocks.js";

const getEditor = setupTestEnv();

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

function getPosBeforeSelectedBlock() {
return getEditor().transact(
(tr) => getBlockInfoFromSelection(tr).bnBlock.beforePos,
(tr) => getBlockInfoFromSelection(tr).block.beforePos,
);
}

Expand Down
Loading
Loading