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
5 changes: 5 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@
"import": "./dist/extensions.js",
"require": "./dist/extensions.cjs"
},
"./internal": {
"types": "./types/src/internal.d.ts",
"import": "./dist/internal.js",
"require": "./dist/internal.cjs"
},
"./yjs": {
"types": "./types/src/yjs/index.d.ts",
"import": "./dist/yjs.js",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Fragment, Slice } from "prosemirror-model";
import { Fragment, Node, NodeType, Slice } from "prosemirror-model";
import type { Transaction } from "prosemirror-state";
import { ReplaceStep } from "prosemirror-transform";
import { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js";
Expand All @@ -8,10 +8,94 @@ import {
InlineContentSchema,
StyleSchema,
} from "../../../../schema/index.js";
import { isContainerBlockNode } from "../../../../schema/blocks/children.js";
import { blockToNode } from "../../../nodeConversions/blockToNode.js";
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
import { getNodeById } from "../../../nodeUtil.js";
import { getPmSchema } from "../../../pmUtil.js";
import {
descendToFirstInsertionPos,
descendToLastInsertionPos,
} 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.
*
* 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";

/**
* Resolves a `placement` against a reference block into the document position
* a node of `nodeType` should be inserted at, or `null` when the reference
* block cannot take it there.
*
* Shared by `insertBlocks` and the move commands, so "does this block fit
* here?" is answered in one place. The answer comes from the schema's content
* matches rather than from a hand-written rule, so a container's `children`
* config decides it.
*
* `wrapIn` is set when the position only becomes valid once the nodes are
* wrapped: a regular block with no children yet has no `blockGroup` for them
* to go in, so one is created around them.
*/
export function getInsertionPos(
doc: Node,
reference: { node: Node; posBeforeNode: number },
placement: BlockPlacement,
nodeType: NodeType,
): { pos: number; wrapIn?: NodeType } | null {
const { node, posBeforeNode } = reference;

if (placement === "before" || placement === "after") {
const pos =
placement === "before" ? posBeforeNode : posBeforeNode + node.nodeSize;
const $pos = doc.resolve(pos);

return $pos.parent.contentMatchAt($pos.index()).matchType(nodeType)
? { pos }
: null;
}

// A container holds its children itself, or, when it has content of its
// own, in its generated `__children` node, which the descent helpers step
// into. The helpers ignore sealed boundaries by default, which is correct
// here: an explicit `insertBlocks` placement is an intentional crossing.
if (isContainerBlockNode(node)) {
const pos =
placement === "start"
? descendToFirstInsertionPos(node, posBeforeNode, nodeType)
: descendToLastInsertionPos(node, posBeforeNode, nodeType);

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

// A regular block keeps its children in a `blockGroup` that only exists once
// it has some.
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 =
placement === "start"
? descendToFirstInsertionPos(node.lastChild!, blockGroupPos, nodeType)
: descendToLastInsertionPos(node.lastChild!, blockGroupPos, nodeType);

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

export function insertBlocks<
BSchema extends BlockSchema,
Expand All @@ -21,7 +105,7 @@ export function insertBlocks<
tr: Transaction,
blocksToInsert: PartialBlock<BSchema, I, S>[],
referenceBlock: BlockIdentifier,
placement: "before" | "after" = "before",
placement: BlockPlacement = "before",
): Block<BSchema, I, S>[] {
const id =
typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id;
Expand All @@ -37,14 +121,30 @@ export function insertBlocks<
throw new Error(`Block with ID ${id} not found`);
}

let pos = posInfo.posBeforeNode;
if (placement === "after") {
pos += posInfo.node.nodeSize;
if (nodesToInsert.length === 0) {
return [];
}

tr.step(
new ReplaceStep(pos, pos, new Slice(Fragment.from(nodesToInsert), 0, 0)),
const target = getInsertionPos(
tr.doc,
posInfo,
placement,
nodesToInsert[0].type,
);
if (!target) {
throw new Error(
`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.`),
);
}

const fragment = target.wrapIn
? Fragment.from(target.wrapIn.create(null, nodesToInsert))
: Fragment.from(nodesToInsert);

tr.step(new ReplaceStep(target.pos, target.pos, new Slice(fragment, 0, 0)));

// Now that the `PartialBlock`s have been converted to nodes, we can
// re-convert them into full `Block`s.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// @vitest-environment node
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
} from "vite-plus/test";

import { BlockNoteSchema } from "../../../../blocks/BlockNoteSchema.js";
import { defaultBlockSpecs } from "../../../../blocks/defaultBlocks.js";
import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import { createBlockSpec } from "../../../../schema/blocks/createSpec.js";

// The editor stays headless, so these blocks are never rendered. `render`
// only has to exist for `createBlockSpec` to accept the spec.
const container = (type: string, config: Record<string, unknown>) =>
createBlockSpec({ type, propSchema: {}, ...config } as any, {
render: () => {
throw new Error("not rendered in this suite");
},
})();

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.
box: container("box", {
content: "none",
children: { allow: "any", min: 0 },
}),
titledBox: container("titledBox", {
content: "inline",
children: { allow: "any", min: 0 },
}),
// A container that only accepts other containers, so an insertion has to
// descend a level to find a place for a regular block.
grid: container("grid", {
content: "none",
children: { allow: ["cell"], min: 2 },
}),
cell: container("cell", {
content: "none",
children: { allow: "any" },
placement: "containerOnly",
}),
// A container that is full once it has one child.
single: container("single", {
content: "none",
children: { allow: "any", max: 1 },
}),
} as const,
});

let editor: BlockNoteEditor<any, any, any>;

beforeAll(() => {
editor = BlockNoteEditor.create({ schema }) as any;
});

afterAll(() => {
editor._tiptapEditor.destroy();
editor = undefined as any;
});

beforeEach(() => {
editor.replaceBlocks(editor.document, [
{ id: "p-0", type: "paragraph", content: "Paragraph 0" },
]);
});

describe('insertBlocks "start" / "end"', () => {
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");

expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([
"first",
"last",
]);
});

it("prepends and appends around existing children", () => {
editor.replaceBlocks(editor.document, [
{
id: "b-0",
type: "box",
children: [{ id: "existing", type: "paragraph", content: "Existing" }],
},
{ id: "trailing", type: "paragraph", content: "" },
]);

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

expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([
"first",
"existing",
"last",
]);
});

it("inserts into a childless container that has its own content", () => {
editor.replaceBlocks(editor.document, [
{ id: "t-0", type: "titledBox", content: "Title" },
{ id: "trailing", type: "paragraph", content: "" },
]);
expect(editor.getBlock("t-0")!.children).toHaveLength(0);

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

const toggle = editor.getBlock("t-0")!;
// The title is content, not a child. A nested insertion must not land
// in it, or before it.
expect(toggle.content).toEqual([
{ type: "text", text: "Title", styles: {} },
]);
expect(toggle.children.map((child) => child.id)).toEqual(["first", "last"]);
});

it("descends into a nested container that accepts the block", () => {
editor.replaceBlocks(editor.document, [
{
id: "g-0",
type: "grid",
children: [
{ id: "c-0", type: "cell" },
{ id: "c-1", type: "cell" },
],
},
{ id: "trailing", type: "paragraph", content: "" },
]);

// `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");

const grid = editor.getBlock("g-0")!;
expect(grid.children[0].children.map((child: any) => child.id)).toContain(
"first",
);
expect(grid.children[1].children.map((child: any) => child.id)).toContain(
"last",
);
});

it("nests under a regular block, with or without existing children", () => {
editor.replaceBlocks(editor.document, [
{ 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");

expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([
"first",
"existing",
]);
});

it("throws when the container has no room for the block", () => {
editor.replaceBlocks(editor.document, [
{
id: "s-0",
type: "single",
children: [{ id: "only", type: "paragraph" }],
},
{ id: "trailing", type: "paragraph", content: "" },
]);

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

it("throws when a sibling placement isn't allowed either", () => {
editor.replaceBlocks(editor.document, [
{
id: "g-0",
type: "grid",
children: [
{ id: "c-0", type: "cell" },
{ id: "c-1", type: "cell" },
],
},
{ id: "trailing", type: "paragraph", content: "" },
]);

// `grid`'s children are `cell`s only, so a paragraph can't become one's
// sibling. Previously this threw a raw ProseMirror `ReplaceError`.
expect(() =>
editor.insertBlocks([{ type: "paragraph" }], "c-0", "after"),
).toThrow(/its parent does not accept it/);
});
});
Loading
Loading