Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .claude/skills/testing-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ In most cases, once a feature, bug fix, or other modification has been written,

`packages/*/src/**/*.browser.test.{ts,tsx}`: Unit tests for browser-only implementations (e.g. canvas or DOM-dependent code) live next to the code they test, with a `.browser.test` suffix. They run as part of the browser suite in Docker (the `tests` package's browser config includes them); the packages' own node-mode vitest configs exclude them. Use this when the unit under test genuinely needs a real browser — everything else should be a plain node unit test.

### Choosing between `/tests/src/unit` and a colocated test

Both are unit tests, so "is this a unit or an integration test" is the wrong question. Pick by harness:

- `/tests/src/unit` exists to fan a **single case** out across many output formats (BlockNote HTML, external HTML, Markdown, PM nodes) and across clipboard and selection behaviour, all against the one shared `testSchema`. You contribute a case by appending to a `*TestInstances.ts` array, not by adding a test file. If the schema needs a new block type to express the case, add it to `tests/src/unit/core/testSchema.ts` (or `react/testSchema.tsx`).
- A colocated test in `packages/*/src` pins the behaviour of one function or module, and is free to declare its own schema fixture. Use it when the assertion is about internal shape (node structure, transaction steps, return values) rather than about a serialization format.

If a case belongs in both, prefer `/tests/src/unit`: one entry there produces coverage in every format at once.

### Naming a colocated test file

- When the suite covers one source file, mirror its name: `blockToNode.ts` gets `blockToNode.test.ts`.
- When it covers a behaviour spanning several modules, name it after the behaviour and put it in the directory that owns that behaviour: `containers/containers.test.ts`, `commands/insertBlocks/insertPlacement.test.ts`. This is common and fine; roughly a third of colocated test files have no same-named source file.
- Don't name a file after a schema or config feature (`contentContainers.test.ts`). Those names go stale when the feature is renamed or dropped, and the file is then stranded under a name that no longer maps to anything. Name it after the code that implements the feature instead.

### End-to-End Tests

`tests/src/end-to-end`: Tests that need a real browser and span multiple packages go here — chiefly tests which interact with the editor UI or simulate user interaction, but also browser integration tests that exercise complete flows without interaction (e.g. exporting a full document, static rendering). New subdirectories can be added if the functionality being tested is not covered by any of the existing ones. Important note about existing E2E tests - many are written poorly and should only loosely be used as reference. We want to avoid abstraction layers and `waitForTimeout` as much as possible.
Expand Down
17 changes: 10 additions & 7 deletions docs/content/docs/features/custom-schemas/container-blocks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ The demo below puts this together: a callout block that can contain any other bl
| `min` / `max` | `1` / unbounded | How many children are allowed. Compiled into the editor schema. |
| `default` | none | Partial blocks to create the container with when it's inserted without an explicit `children` array, and the source of `"refill"` top-ups. Validated against the rest of the config when the schema is created. See [Defaults and refilling](#defaults-and-refilling). |
| `whenEmptied` | `"refill"` | What happens when fewer non-empty children remain than `min`: `"refill"` tops the container back up from `default`; `"unwrap"` replaces the container with its surviving children, or removes it entirely when none are left. Column lists use `"unwrap"` so emptied columns disappear and a one-column list dissolves. |
| `boundary` | `"isolated"` | What crosses the container's edge: the caret, selections, or nothing. See [Boundaries](#boundaries). |
| `boundary` | `"open"` | Whether editing gestures cross the container's edge. See [Boundaries](#boundaries). |

`placement` sits next to `children` on the block config rather than inside it, because it's a fact about *this* block rather than about its children:

Expand All @@ -109,14 +109,15 @@ The same template drives `whenEmptied: "refill"`. When a refill container's non-

## Boundaries

`boundary` declares what may cross a container's edge. On an open or isolated edge, editing gestures move blocks across it: Backspace at the start of the first child moves that child out, and Enter on an empty last child escapes below the container. A sealed edge blocks all of that, so the container behaves as a single unit.
`boundary` declares whether editing gestures cross a container's edge. On an open edge they move blocks across it: Backspace at the start of the first child moves that child out, and Enter on an empty last child escapes below the container. A sealed edge blocks all of that, so the container behaves as a single unit.

| Value | Crosses the edge | Use for |
| --- | --- | --- |
| `"open"` | Caret, editing gestures, and text selections. A selection can span children and reach outside the container. | Flow regions where a selection should cross child boundaries, like the columns of a `columnList`. |
| `"isolated"` (default) | Caret and editing gestures, but not a text selection. | Most containers, like a callout. |
| `"open"` (default) | The caret and editing gestures. | Containers that are part of the surrounding flow of text, like a callout or the columns of a `columnList`. |
| `"sealed"` | Nothing implicitly. The caret won't wander in, and from outside the container selects and deletes as one unit. | Compartments that should stay put, like a table cell. |

A seal binds gestures only. A text selection may span any edge, so a drag out of a sealed container still selects across it.

```typescript
// A cell: holds any blocks, but nothing crosses its edge implicitly.
children: { allow: "any", boundary: "sealed" },
Expand Down Expand Up @@ -169,8 +170,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 All @@ -183,7 +184,9 @@ Configurations are checked when the schema is created, and fail up front with a
- an `allow` array naming an unknown type, or naming a regular block type (per-type filtering of regular blocks is [not yet supported](#restricting-children));
- `children` combined with any `content` other than `"none"`;
- a `placement: "containerOnly"` block that no container's `allow` array names, or `placement: "containerOnly"` on a regular block;
- container cycles: a container that (transitively) requires a child that requires it back could never be created. An `allow` that permits regular blocks breaks the cycle, since they're always satisfiable.
- container cycles: a container that (transitively) requires a child that requires it back could never be created. An `allow` that permits regular blocks breaks the cycle, since they're always satisfiable. `allow: "containers"` with `min: 1` is the same problem, since the container counts as a container itself.

Documents are checked too. `initialContent` that doesn't fit the schema throws when the editor is created, rather than loading in a broken state. This matters when you change a `children` config on a schema whose documents are already saved somewhere: a stored document that no longer fits, say a `columnList` left with a single column, now fails at load. Migrate those documents before shipping the change.

## Parsing HTML into a container

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 @@ -7,7 +7,7 @@
.item {
border-radius: 0.5rem;
flex: 1;
overflow: hidden;
overflow: auto;
}

.item.bordered {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,22 @@ 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";
import { getPmSchema } from "../../../pmUtil.js";
import {
descendToFirstInsertionPos,
descendToLastInsertionPos,
} from "../../containers/containerNav.js";
import { descendToInsertionPos } 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,48 +47,43 @@ 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;
const $pos = doc.resolve(pos);

return $pos.parent.contentMatchAt($pos.index()).matchType(nodeType)
// `canReplaceWith` rather than a bare content match: the nodes already
// after the position have to still fit once the new one is spliced in.
return $pos.parent.canReplaceWith($pos.index(), $pos.index(), nodeType)
? { pos }
: 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 navigation helpers stop at sealed boundaries but this caller lets
// them cross: an explicit `insertBlocks` placement is an intentional
// crossing.
const { pos } = descendToInsertionPos(
info,
nodeType,
placement === "first-child" ? "first" : "last",
{ allowCrossingSeals: true },
);

return pos === null ? null : { pos };
return pos === undefined ? 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 +125,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
Loading
Loading