diff --git a/.claude/skills/testing-skill/SKILL.md b/.claude/skills/testing-skill/SKILL.md
index 6ff6af56a9..969d4b0375 100644
--- a/.claude/skills/testing-skill/SKILL.md
+++ b/.claude/skills/testing-skill/SKILL.md
@@ -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.
diff --git a/docs/content/docs/features/custom-schemas/container-blocks.mdx b/docs/content/docs/features/custom-schemas/container-blocks.mdx
new file mode 100644
index 0000000000..0d03aace2e
--- /dev/null
+++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx
@@ -0,0 +1,205 @@
+---
+title: Container Blocks
+description: Learn how to create custom blocks that hold other blocks as their body
+---
+
+# Container Blocks
+
+A *container block* is a custom block that holds other blocks as its body, like a Notion-style callout wrapping a paragraph and a code block, or a multi-column layout.
+
+## Declaring a Container Block
+
+Add the `children` option to your block config (created with [`createBlockSpec` or `createReactBlockSpec`](/docs/features/custom-schemas/custom-blocks)). The only required field is `allow`, so the smallest container is:
+
+```typescript
+import { createReactBlockSpec } from "@blocknote/react";
+
+const createCallout = createReactBlockSpec(
+ {
+ type: "callout",
+ propSchema: {},
+ content: "none",
+ // Makes this a container: its body is other blocks.
+ children: { allow: "any" },
+ },
+ {
+ // Child blocks mount into the element you attach `contentRef` to.
+ render: (props) =>
,
+ },
+);
+```
+
+`children: { allow: "any" }` accepts any block, requires at least one, and never throws. When a container is created without children, BlockNote fills it with whatever its schema requires.
+
+A container block always declares `content: "none"`: its body is its children. Combining `children` with any other `content` is a schema-creation error. For an editable title or caption, use a string prop rendered as an ``, as the demo below does.
+
+At runtime the contained blocks live on `block.children`, the same field used for indented (nested) blocks. In fact, every regular block behaves as if it were declared with `children: { allow: "any", min: 0 }`; declaring `children` yourself is how you take control of the counts, the allowed types, and the rendering of that same field:
+
+```json
+{
+ "id": "callout-1",
+ "type": "callout",
+ "props": {},
+ "children": [
+ {
+ "id": "para-1",
+ "type": "paragraph",
+ "content": [{ "type": "text", "text": "Hello", "styles": {} }],
+ "children": []
+ }
+ ]
+}
+```
+
+### Where children render
+
+There is only one placement mechanism, and it is the one you already use for inline content. `contentRef` (React) / `contentDOM` (vanilla) marks the block's editable region. What goes in that region depends on the block:
+
+| block | `contentRef` element holds |
+| --- | --- |
+| `content: "inline"`, no `children` | its inline content |
+| `content: "none"` + `children` | its child blocks |
+
+A `content: "none"` block *without* `children` is the only kind with nothing to place, and it's the only kind that isn't offered a `contentRef` at all.
+
+Container blocks own their entire outer DOM. BlockNote doesn't wrap them in the usual block element: whatever element your `render` returns *is* the block's element, and BlockNote stamps the attributes it relies on for parsing and UI positioning onto it (`data-node-type`, `data-id`, and each non-default prop as a `data-*` attribute). You write a plain `
` and `data-flavor="info"` lands on it, in the live editor and in serialized HTML alike.
+
+
+ _The framework wrappers React puts above your element carry `display:
+ contents`, so they contribute no box and your element lays out exactly as if
+ it were the block's root. Selection is mirrored onto it as a `data-selected`
+ attribute, so `[data-selected]` is what you style for the selected state._
+
+
+The demo below puts this together: a callout block that can contain any other blocks. Its title is a regular `` backed by a string prop rather than document content — the pattern to reach for whenever a container needs an editable heading, caption, or label of its own:
+
+
+
+## `children` options
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `allow` | (required) | What may appear as a child: `"any"`, `"blocks"`, `"containers"`, or an array of container block types. See [Restricting children](#restricting-children). |
+| `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` | `"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:
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `placement` | `"anywhere"` | `"containerOnly"` restricts the block to containers that name it in their `children.allow` array, like a `column`, which only makes sense inside a `columnList`. It also requires the block to be a container itself. `"anywhere"` is valid on any block; on a regular block it simply restates the default. |
+
+Purely behavioral options that apply to *every* block kind stay in the block implementation's `meta`:
+
+| Meta option | Default | Description |
+| --- | --- | --- |
+| `draggable` | `true` | Whether the block gets a side menu drag handle. A block that opts out is skipped when looking for a handle, so the handle falls through to the nearest draggable ancestor. |
+
+
+ _`whenEmptied` never destroys typed text: only empty children are dropped._
+
+
+## Defaults and refilling
+
+`default` is an insertion template: a container inserted without an explicit `children` array is created with those blocks. Omit it and BlockNote fills the container with empty blocks its schema accepts.
+
+The same template drives `whenEmptied: "refill"`. When a refill container's non-empty children drop below `min`, say `k` remain, BlockNote appends `default[k]` through `default[min - 1]` at the end, falling back to empty blocks where `default` is absent or has no entry for a position. A checklist with `min: 2` and a two-entry `default` that loses its second item gets `default[1]` back, not a bare paragraph.
+
+## Boundaries
+
+`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"` (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" },
+placement: "containerOnly",
+```
+
+The block manipulation API ignores `boundary` entirely. An `insertBlocks` call is an intentional crossing, so it can always place content inside a sealed container.
+
+## Restricting children
+
+`allow` takes one of four forms:
+
+```typescript
+allow: "any" | "blocks" | "containers" | string[]
+```
+
+- `"any"`: any regular block, plus any container placeable anywhere.
+- `"blocks"`: regular blocks only, no containers.
+- `"containers"`: any anywhere-placeable container, no regular blocks.
+- `string[]`: only the named container block types.
+
+The wildcard forms (`"any"`, `"containers"`) exclude `placement: "containerOnly"` types: a `column` never shows up inside your callout just because the callout accepts "any" block. A containerOnly type appears only where a parent names it in an array.
+
+The array form is exact because each container block type is distinct in the schema, while every regular block (paragraph, heading, code block) shares one underlying type. So "only headings" is not something the schema can enforce yet. Naming a regular block type in the array is a startup error; per-type filtering of regular blocks is not yet supported, and the array is where it will land later with no API change.
+
+This is exactly how the multi-column blocks are defined:
+
+```typescript
+// The outer container: only columns, at least two of them;
+// unwraps when it drops to one, and selections span its columns.
+children: {
+ allow: ["column"],
+ min: 2,
+ whenEmptied: "unwrap",
+ boundary: "open",
+}
+
+// The column: holds any blocks, but only lives inside a columnList.
+children: { allow: "any" },
+placement: "containerOnly",
+```
+
+## Inserting into a container
+
+[`editor.insertBlocks`](/docs/reference/editor/manipulating-content#inserting-blocks) takes two nested placements alongside the sibling ones:
+
+```typescript
+// Siblings of the reference block:
+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, "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.
+
+## Validation
+
+Configurations are checked when the schema is created, and fail up front with a message naming the block. Beyond unknown block types and impossible `default` children, this catches:
+
+- an `allow` that permits nothing: an empty array, or a wildcard form when no anywhere-placeable container exists;
+- 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. `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
+
+Containers parse like any other custom block. The default rule matches `[data-node-type=""]` so BlockNote's own HTML round-trips, and `implementation.parse` recognizes foreign HTML. Both work exactly as described for [custom blocks](/docs/features/custom-schemas/custom-blocks).
+
+What's specific to a container is its body. By default BlockNote parses the element's children with the normal block rules, so `
…
…
` becomes a card with a paragraph and a heading. Supply `parseContent` only when you need to build the body yourself.
+
+
+ _`allow` does not filter what a user pastes. Content your container rejects is
+ placed after the container rather than dropped. `allow` constrains the
+ document model, not the parser._
+
+
+## Interop behavior
+
+Containers serialize to a `
` with their children nested inside, and round-trip losslessly. For lossy targets you place the children yourself: return a `childrenDOM` from `toExternalHTML` (this is how toggles export as ``), and give container blocks an explicit mapping in the DOCX, ODT, email, Typst, and PDF exporters, which throw on a missing one. That mapping receives the container's rendered children as its last argument and decides where they go — the exporters do not append them after the container's own output. Markdown flattens containers, exporting their children in order.
diff --git a/docs/content/docs/features/custom-schemas/custom-blocks.mdx b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
index ff25cf838c..204381e785 100644
--- a/docs/content/docs/features/custom-schemas/custom-blocks.mdx
+++ b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
@@ -72,6 +72,12 @@ type BlockConfig = {
alert, so we set `content` to `"inline"`._
+
+ _A `content: "none"` block can also hold other blocks as its body by
+ declaring the `children` option. See [Container
+ Blocks](/docs/features/custom-schemas/container-blocks)._
+
+
`propSchema:` The `PropSchema` specifies the props that the block supports. Block props (properties) are data stored with your Block in the document, and can be used to customize its appearance or behavior.
```typescript
diff --git a/docs/content/docs/features/export/typst.mdx b/docs/content/docs/features/export/typst.mdx
index 4a5e0ddaa3..39e908cba7 100644
--- a/docs/content/docs/features/export/typst.mdx
+++ b/docs/content/docs/features/export/typst.mdx
@@ -120,6 +120,22 @@ For a block with inline content, render it the way the default mappings do:
`exporter.transformInlineContent(block.content).join("")` (inline results are
markup strings, so plain concatenation composes them).
+### Container blocks
+
+A [container block](/docs/features/custom-schemas/container-blocks) holds
+child blocks, and its mapping decides where they go: the exporter renders the
+children first and passes them in as the mapping's last argument, rather than
+appending them after the container's own output. A container without a
+mapping is an error rather than a silent omission, since dropping it would
+drop its children too.
+
+```typescript
+myContainer: (block, exporter, nestingLevel, numberedListIndex, children) =>
+ `#rect(width: 100%)[${children.join("\n\n")}]`,
+```
+
+Separate the children with a blank line, as above, if each should stay its own
+block — a single `\n` is only a soft break in Typst markup.
### Math & diagram blocks
diff --git a/docs/content/docs/reference/editor/manipulating-content.mdx b/docs/content/docs/reference/editor/manipulating-content.mdx
index 1a9c97c222..5cde60c29c 100644
--- a/docs/content/docs/reference/editor/manipulating-content.mdx
+++ b/docs/content/docs/reference/editor/manipulating-content.mdx
@@ -141,11 +141,11 @@ editor.forEachBlock((block) => {
insertBlocks(
blocksToInsert: PartialBlock[],
referenceBlock: BlockIdentifier,
- placement: "before" | "after" = "before"
+ placement: "before" | "after" | "first-child" | "last-child" = "before"
): void
```
-Inserts new blocks relative to an existing block.
+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
@@ -164,6 +164,13 @@ editor.insertBlocks(
"existing-block-id",
"after",
);
+
+// Insert a paragraph as the last child of a container block
+editor.insertBlocks(
+ [{ type: "paragraph", content: "Nested paragraph" }],
+ "container-block-id",
+ "last-child",
+);
```
### Updating Blocks
diff --git a/examples/06-custom-schema/09-container-block/.bnexample.json b/examples/06-custom-schema/09-container-block/.bnexample.json
new file mode 100644
index 0000000000..3de7330631
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/.bnexample.json
@@ -0,0 +1,15 @@
+{
+ "playground": true,
+ "docs": true,
+ "author": "nickthesick",
+ "tags": [
+ "Intermediate",
+ "Blocks",
+ "Custom Schemas",
+ "Suggestion Menus",
+ "Slash Menu"
+ ],
+ "dependencies": {
+ "react-icons": "^5.5.0"
+ }
+}
diff --git a/examples/06-custom-schema/09-container-block/README.md b/examples/06-custom-schema/09-container-block/README.md
new file mode 100644
index 0000000000..070dd71987
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/README.md
@@ -0,0 +1,22 @@
+# Container Block
+
+In this example, we create a custom `Callout` block that holds other blocks as its body, like a Notion-style callout wrapping a paragraph followed by a code block.
+
+The block declares the `children` config on `BlockConfig`. `children: { min: 1, default: [{ type: "paragraph" }] }` makes it a container: its child blocks mount into the element the render passes `contentRef` to, and live on `block.children` at runtime.
+
+The callout's **title** demonstrates the complementary "string prop slot" pattern: a field that doesn't need rich text, comments, or multiplayer cursors can live in a plain string prop, edited through a regular `` rendered inside the block (in a `contentEditable={false}` wrapper) and committed via `editor.updateBlock`. A field that _is_ prose belongs in the block's own `content: "inline"` instead.
+
+We also wire up a Slash Menu item to insert the callout, and render the document JSON next to the editor so you can inspect the structure of the nested blocks.
+
+**Try it out:**
+
+- Press the "/" key inside the callout's body and add a code block, heading, or list.
+- Type a title into the title field. It's stored on `block.props.title`, not as document content.
+- Watch the JSON panel on the right update as you edit; the callout's children appear in `block.children`.
+- Insert a new callout via the Slash Menu (search "callout").
+
+**Relevant Docs:**
+
+- [Container Blocks](/docs/features/custom-schemas/container-blocks)
+- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)
+- [Editor Setup](/docs/getting-started/editor-setup)
diff --git a/examples/06-custom-schema/09-container-block/index.html b/examples/06-custom-schema/09-container-block/index.html
new file mode 100644
index 0000000000..19321f77b5
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+ Container Block
+
+
+
+
+
+
+
diff --git a/examples/06-custom-schema/09-container-block/main.tsx b/examples/06-custom-schema/09-container-block/main.tsx
new file mode 100644
index 0000000000..1260513388
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/main.tsx
@@ -0,0 +1,11 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./src/App.jsx";
+
+const root = createRoot(document.getElementById("root")!);
+root.render(
+
+
+ ,
+);
diff --git a/examples/06-custom-schema/09-container-block/package.json b/examples/06-custom-schema/09-container-block/package.json
new file mode 100644
index 0000000000..29778f9255
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@blocknote/example-custom-schema-container-block",
+ "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "type": "module",
+ "private": true,
+ "version": "0.12.4",
+ "scripts": {
+ "start": "vite",
+ "dev": "vite",
+ "build:prod": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@blocknote/ariakit": "latest",
+ "@blocknote/core": "latest",
+ "@blocknote/mantine": "latest",
+ "@blocknote/react": "latest",
+ "@blocknote/shadcn": "latest",
+ "@mantine/core": "^9.0.2",
+ "@mantine/hooks": "^9.0.2",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3",
+ "react-icons": "^5.5.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.3",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "vite": "^8.0.0"
+ }
+}
diff --git a/examples/06-custom-schema/09-container-block/src/App.tsx b/examples/06-custom-schema/09-container-block/src/App.tsx
new file mode 100644
index 0000000000..3d6cf55ba1
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/src/App.tsx
@@ -0,0 +1,118 @@
+import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core";
+import {
+ filterSuggestionItems,
+ insertOrUpdateBlockForSlashMenu,
+} from "@blocknote/core/extensions";
+import "@blocknote/core/fonts/inter.css";
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+import {
+ SuggestionMenuController,
+ getDefaultReactSlashMenuItems,
+ useCreateBlockNote,
+} from "@blocknote/react";
+import { useEffect, useState } from "react";
+import { RiChatQuoteLine } from "react-icons/ri";
+
+import { createCallout } from "./Callout";
+import "./styles.css";
+
+// Schema with the default blocks plus our custom Callout container block.
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ callout: createCallout(),
+ },
+});
+
+// Slash menu item to insert a Callout. Because Callout is a container block,
+// inserting one with no children causes BlockNote to seed it with the block's
+// configured `children.default` (a single paragraph here).
+const insertCallout = (editor: typeof schema.BlockNoteEditor) => ({
+ title: "Callout",
+ subtext: "Container block that wraps other blocks",
+ onItemClick: () =>
+ insertOrUpdateBlockForSlashMenu(editor, {
+ type: "callout",
+ }),
+ aliases: ["callout", "container", "alert", "note", "tip", "info"],
+ group: "Basic blocks",
+ icon: ,
+});
+
+type AppBlock = (typeof schema.BlockNoteEditor)["document"][number];
+
+export default function App() {
+ const [blocks, setBlocks] = useState([]);
+
+ const editor = useCreateBlockNote({
+ schema,
+ initialContent: [
+ {
+ type: "paragraph",
+ content: "Welcome! This demo shows the new container block kind.",
+ },
+ {
+ type: "callout",
+ props: { flavor: "tip" },
+ children: [
+ {
+ type: "paragraph",
+ content: "Callouts can hold any block as their body.",
+ },
+ {
+ type: "paragraph",
+ content:
+ "Try pressing '/' inside this callout to add a heading or code block.",
+ },
+ ],
+ },
+ {
+ type: "paragraph",
+ content: "Press '/' anywhere to insert a new Callout.",
+ },
+ {
+ type: "paragraph",
+ },
+ ],
+ });
+
+ useEffect(() => setBlocks(editor.document), [editor]);
+
+ return (
+
+ );
+}
diff --git a/examples/06-custom-schema/09-container-block/src/Callout.tsx b/examples/06-custom-schema/09-container-block/src/Callout.tsx
new file mode 100644
index 0000000000..b150cead50
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/src/Callout.tsx
@@ -0,0 +1,104 @@
+import { createReactBlockSpec } from "@blocknote/react";
+import { MdCheckCircle, MdInfo, MdLightbulb, MdWarning } from "react-icons/md";
+
+import "./styles.css";
+
+// The flavors of callout the user can switch between.
+export const calloutTypes = [
+ { value: "tip", title: "Tip", icon: MdLightbulb },
+ { value: "info", title: "Info", icon: MdInfo },
+ { value: "warning", title: "Warning", icon: MdWarning },
+ { value: "success", title: "Success", icon: MdCheckCircle },
+] as const;
+
+// The Callout block. Declared with `content: "none"` plus the `children`
+// config: the block hosts arbitrary child blocks in its body, exposed at
+// runtime as `block.children`.
+//
+// The callout's title shows a related pattern: content that shouldn't be
+// part of the rich-text document (no formatting, comments, or multiplayer
+// cursors needed) can live in a plain string prop, edited through a regular
+// rendered inside the block.
+export const createCallout = createReactBlockSpec(
+ {
+ type: "callout",
+ propSchema: {
+ flavor: {
+ default: "tip",
+ values: ["tip", "info", "warning", "success"],
+ },
+ title: {
+ default: "",
+ },
+ },
+ content: "none",
+ // `children: { allow: "any" }` is the entire container declaration: any
+ // block is allowed, at least one is required, and BlockNote fills the
+ // callout with an empty paragraph when it's created. `min` / `max` /
+ // `default` / `whenEmptied` / `boundary` tune this.
+ children: { allow: "any" },
+ },
+ {
+ render: (props) => {
+ const flavor =
+ calloutTypes.find((c) => c.value === props.block.props.flavor) ??
+ calloutTypes[0];
+ const Icon = flavor.icon;
+
+ const cycleFlavor = () => {
+ const idx = calloutTypes.findIndex(
+ (c) => c.value === props.block.props.flavor,
+ );
+ const next = calloutTypes[(idx + 1) % calloutTypes.length];
+ props.editor.updateBlock(props.block, {
+ type: "callout",
+ props: { flavor: next.value },
+ });
+ };
+
+ const commitTitle = (title: string) => {
+ if (title !== props.block.props.title) {
+ props.editor.updateBlock(props.block, {
+ type: "callout",
+ props: { title },
+ });
+ }
+ };
+
+ return (
+
+
+
+ {/* The title lives in a string prop, not in document content,
+ and is edited via a plain input. `contentEditable={false}`
+ keeps ProseMirror from treating typing here as document
+ input. */}
+
+ );
+}
diff --git a/examples/06-custom-schema/12-container-table/src/Table.tsx b/examples/06-custom-schema/12-container-table/src/Table.tsx
new file mode 100644
index 0000000000..e63b916db4
--- /dev/null
+++ b/examples/06-custom-schema/12-container-table/src/Table.tsx
@@ -0,0 +1,346 @@
+import {
+ createExtension,
+ type Block,
+ type BlockNoteEditor,
+} from "@blocknote/core";
+import { createReactBlockSpec } from "@blocknote/react";
+
+import "./styles.css";
+
+// A table built entirely out of container blocks, without `prosemirror-tables`
+// or the special `"table"` content type. A table is a container of rows, a row
+// is a container of cells, and a cell is a container of arbitrary blocks:
+//
+// table > tableRow > tableCell / tableHeader > (any blocks)
+//
+// The JSON shape is the same `children` array every other container block
+// uses, and every structural operation (add/remove row or column, toggle the
+// header row) is a plain `insertBlocks` / `removeBlocks` / `updateBlock` call.
+
+type AnyEditor = BlockNoteEditor;
+type AnyBlock = Block;
+
+function isCellType(type: string): boolean {
+ return type === "tableCell" || type === "tableHeader";
+}
+
+// ---------------------------------------------------------------------------
+// Cell navigation (Tab / Shift-Tab)
+// ---------------------------------------------------------------------------
+
+// Finds the cell / row / table the text cursor is currently inside, by
+// walking up the ancestor chain with `editor.getParentBlock`. Returns
+// undefined when the cursor isn't in a table.
+function getCellContext(
+ editor: AnyEditor,
+): { cell: AnyBlock; row: AnyBlock; table: AnyBlock } | undefined {
+ let current: AnyBlock | undefined = editor.getTextCursorPosition().block;
+ while (current && !isCellType(current.type)) {
+ current = editor.getParentBlock(current);
+ }
+ if (!current) {
+ return undefined;
+ }
+
+ const row = editor.getParentBlock(current);
+ if (!row || row.type !== "tableRow") {
+ return undefined;
+ }
+ const table = editor.getParentBlock(row);
+ if (!table || table.type !== "table") {
+ return undefined;
+ }
+
+ return { cell: current, row, table };
+}
+
+// Places the cursor inside a cell. Descends through nested tables so the
+// cursor always lands on a block that can actually hold it.
+function placeCursorInCell(
+ editor: AnyEditor,
+ cell: AnyBlock,
+ placement: "start" | "end",
+) {
+ let target = cell;
+ while (
+ target.children.length > 0 &&
+ (target.type === "table" ||
+ target.type === "tableRow" ||
+ isCellType(target.type))
+ ) {
+ target =
+ placement === "start"
+ ? target.children[0]
+ : target.children[target.children.length - 1];
+ }
+ editor.setTextCursorPosition(target, placement);
+}
+
+function createRow(
+ numColumns: number,
+ cellType: "tableCell" | "tableHeader" = "tableCell",
+) {
+ return {
+ type: "tableRow" as const,
+ children: Array.from({ length: numColumns }, () => ({ type: cellType })),
+ };
+}
+
+// Moves the cursor to the next/previous cell, wrapping across rows. Tab past
+// the last cell grows the table by a row, like in a spreadsheet, with a
+// single `insertBlocks` call.
+function moveToAdjacentCell(editor: AnyEditor, direction: 1 | -1): boolean {
+ const context = getCellContext(editor);
+ if (!context) {
+ // Not in a table: let BlockNote's default Tab (indent) behavior run.
+ return false;
+ }
+ const { cell, row, table } = context;
+
+ const rows = table.children;
+ const rowIndex = rows.findIndex((r) => r.id === row.id);
+ const cellIndex = row.children.findIndex((c) => c.id === cell.id);
+
+ let targetRowIndex = rowIndex;
+ let targetCellIndex = cellIndex + direction;
+ if (targetCellIndex >= row.children.length) {
+ targetRowIndex += 1;
+ targetCellIndex = 0;
+ } else if (targetCellIndex < 0) {
+ targetRowIndex -= 1;
+ targetCellIndex =
+ targetRowIndex >= 0 ? rows[targetRowIndex].children.length - 1 : 0;
+ }
+
+ // Shift-Tab at the very first cell: stay put (but consume the key so the
+ // cell's content isn't un-indented out of the table).
+ if (targetRowIndex < 0) {
+ return true;
+ }
+
+ // Tab at the very last cell: append a new row and move into it.
+ if (targetRowIndex >= rows.length) {
+ editor.insertBlocks(
+ [createRow(row.children.length)],
+ rows[rows.length - 1],
+ "after",
+ );
+ const updatedTable = editor.getBlock(table.id);
+ const newRow = updatedTable?.children[updatedTable.children.length - 1];
+ if (newRow) {
+ placeCursorInCell(editor, newRow.children[0], "start");
+ }
+ return true;
+ }
+
+ placeCursorInCell(
+ editor,
+ rows[targetRowIndex].children[targetCellIndex],
+ direction === 1 ? "start" : "end",
+ );
+ return true;
+}
+
+// Registered on the `table` block spec, so the shortcuts are only added when
+// the block is in the schema. Block-spec extensions run before BlockNote's
+// default keyboard handlers, so Tab reaches us before the default indent.
+const TableKeyboardExtension = createExtension({
+ key: "containerTableKeyboard",
+ keyboardShortcuts: {
+ Tab: ({ editor }) => moveToAdjacentCell(editor, 1),
+ "Shift-Tab": ({ editor }) => moveToAdjacentCell(editor, -1),
+ },
+});
+
+// ---------------------------------------------------------------------------
+// Structural operations, using only the public block manipulation API
+// ---------------------------------------------------------------------------
+
+function getTable(editor: AnyEditor, tableId: string): AnyBlock | undefined {
+ const table = editor.getBlock(tableId);
+ return table?.type === "table" ? table : undefined;
+}
+
+export function addRow(editor: AnyEditor, tableId: string) {
+ const table = getTable(editor, tableId);
+ if (!table) {
+ return;
+ }
+ const lastRow = table.children[table.children.length - 1];
+ editor.insertBlocks([createRow(lastRow.children.length)], lastRow, "after");
+}
+
+export function removeRow(editor: AnyEditor, tableId: string) {
+ const table = getTable(editor, tableId);
+ if (!table || table.children.length <= 1) {
+ return;
+ }
+ editor.removeBlocks([table.children[table.children.length - 1]]);
+}
+
+export function addColumn(editor: AnyEditor, tableId: string) {
+ const table = getTable(editor, tableId);
+ if (!table) {
+ return;
+ }
+ editor.transact(() => {
+ for (const row of table.children) {
+ const lastCell = row.children[row.children.length - 1];
+ // Match the row's cell kind, so a header row grows a header cell.
+ editor.insertBlocks([{ type: lastCell.type }], lastCell, "after");
+ }
+ });
+}
+
+export function removeColumn(editor: AnyEditor, tableId: string) {
+ const table = getTable(editor, tableId);
+ if (!table || table.children.some((row) => row.children.length <= 1)) {
+ return;
+ }
+ editor.transact(() => {
+ for (const row of table.children) {
+ editor.removeBlocks([row.children[row.children.length - 1]]);
+ }
+ });
+}
+
+// Flips the first row between header cells and regular cells. Because header
+// cells are a distinct block type (not table metadata), this is just
+// `updateBlock` with a new type. Children are carried over automatically.
+export function toggleHeaderRow(editor: AnyEditor, tableId: string) {
+ const table = getTable(editor, tableId);
+ if (!table) {
+ return;
+ }
+ const firstRow = table.children[0];
+ const allHeaders = firstRow.children.every((c) => c.type === "tableHeader");
+ const type = allHeaders ? "tableCell" : "tableHeader";
+ editor.transact(() => {
+ for (const cell of firstRow.children) {
+ editor.updateBlock(cell, { type });
+ }
+ });
+}
+
+// ---------------------------------------------------------------------------
+// The four block specs
+// ---------------------------------------------------------------------------
+
+// The table itself: a container that only accepts rows. Inserting one with
+// no explicit children seeds it from `children.default`: a header row plus
+// two body rows, three columns wide.
+export const createTable = createReactBlockSpec(
+ {
+ type: "table",
+ propSchema: {},
+ content: "none",
+ children: {
+ allow: ["tableRow"],
+ default: [
+ createRow(3, "tableHeader"),
+ createRow(3, "tableCell"),
+ createRow(3, "tableCell"),
+ ],
+ },
+ },
+ {
+ render: (props) => {
+ // `props.block` is captured at render time; the control handlers
+ // re-fetch the table by id so they always operate on fresh children.
+ const { editor } = props;
+ const tableId = props.block.id;
+
+ // Keep focus (and the text selection) in the editor when clicking the
+ // controls.
+ const keepFocus = (event: React.MouseEvent) => event.preventDefault();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+ // Recognizes pasted foreign HTML tables.
+ parse: (el) => (el.tagName === "TABLE" ? {} : undefined),
+ },
+ [TableKeyboardExtension],
+);
+
+// A row: only lives inside a table (`placement: "containerOnly"`), only
+// holds cells.
+export const createTableRow = createReactBlockSpec(
+ {
+ type: "tableRow",
+ propSchema: {},
+ content: "none",
+ children: { allow: ["tableCell", "tableHeader"] },
+ placement: "containerOnly",
+ },
+ {
+ // No drag handle of its own; the side menu handle falls through to the
+ // table.
+ meta: { draggable: false },
+ render: (props) => (
+
+ ),
+ parse: (el) => (el.tagName === "TR" ? {} : undefined),
+ },
+);
+
+// A cell: holds any blocks, and is `boundary: "sealed"`, so the caret and
+// content never implicitly cross its edge (Backspace at the start of a cell
+// does nothing, Delete at its end doesn't pull the next block in, arrow keys
+// from outside treat the table as a unit). Enter inside a cell just adds
+// another block to the cell.
+export const createTableCell = createReactBlockSpec(
+ {
+ type: "tableCell",
+ propSchema: {},
+ content: "none",
+ children: { allow: "any", boundary: "sealed" },
+ placement: "containerOnly",
+ },
+ {
+ meta: { draggable: false },
+ render: (props) => (
+
+ ),
+ parse: (el) => (el.tagName === "TD" ? {} : undefined),
+ },
+);
+
+// A header cell: identical to a regular cell, but a distinct block type.
+// The structure itself encodes which cells are headers, instead of
+// `headerRows` metadata on the table.
+export const createTableHeader = createReactBlockSpec(
+ {
+ type: "tableHeader",
+ propSchema: {},
+ content: "none",
+ children: { allow: "any", boundary: "sealed" },
+ placement: "containerOnly",
+ },
+ {
+ meta: { draggable: false },
+ render: (props) => (
+
+ ),
+ parse: (el) => (el.tagName === "TH" ? {} : undefined),
+ },
+);
diff --git a/examples/06-custom-schema/12-container-table/src/styles.css b/examples/06-custom-schema/12-container-table/src/styles.css
new file mode 100644
index 0000000000..cfb39149f8
--- /dev/null
+++ b/examples/06-custom-schema/12-container-table/src/styles.css
@@ -0,0 +1,100 @@
+.wrapper {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.item {
+ border-radius: 0.5rem;
+ flex: 1;
+ overflow: hidden;
+}
+
+.item.bordered {
+ border: 1px solid gray;
+}
+
+.item pre {
+ border-radius: 0.5rem;
+ height: 100%;
+ overflow: auto;
+ padding-block: 1rem;
+ padding-inline: 54px;
+ width: 100%;
+ white-space: pre-wrap;
+}
+
+/* The grid is plain CSS tables on divs. The React node-view wrappers between
+ the regions carry `display: contents`, so the row and cell boxes end up
+ direct children of the table box as far as layout is concerned. */
+.container-table {
+ flex-grow: 1;
+ min-width: 0;
+}
+
+.container-table-rows {
+ display: table;
+ border-collapse: collapse;
+ width: 100%;
+ table-layout: fixed;
+}
+
+.container-table-row {
+ display: table-row;
+}
+
+.container-table-cell {
+ display: table-cell;
+ border: 1px solid #d0d0d0;
+ padding: 4px 8px;
+ vertical-align: top;
+}
+
+.container-table-header {
+ background-color: #f3f4f6;
+ font-weight: 600;
+}
+
+[data-color-scheme="dark"] .container-table-cell {
+ border-color: #4b4b4b;
+}
+
+[data-color-scheme="dark"] .container-table-header {
+ background-color: #2e2e2e;
+}
+
+.container-table-controls {
+ display: flex;
+ gap: 4px;
+ padding-top: 4px;
+ /* Only reveal the controls while working in the table. */
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+
+.container-table:hover .container-table-controls,
+.container-table:focus-within .container-table-controls {
+ opacity: 1;
+}
+
+.container-table-controls button {
+ border: 1px solid #d0d0d0;
+ border-radius: 4px;
+ background: none;
+ color: inherit;
+ font-size: 0.75rem;
+ padding: 2px 8px;
+ cursor: pointer;
+}
+
+.container-table-controls button:hover {
+ background-color: #f3f4f6;
+}
+
+[data-color-scheme="dark"] .container-table-controls button {
+ border-color: #4b4b4b;
+}
+
+[data-color-scheme="dark"] .container-table-controls button:hover {
+ background-color: #2e2e2e;
+}
diff --git a/examples/06-custom-schema/12-container-table/tsconfig.json b/examples/06-custom-schema/12-container-table/tsconfig.json
new file mode 100644
index 0000000000..2aa62c56e6
--- /dev/null
+++ b/examples/06-custom-schema/12-container-table/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "composite": true,
+ "paths": {
+ "@shared/*": ["../../../shared/*"]
+ }
+ },
+ "include": ["."],
+ "__ADD_FOR_LOCAL_DEV_references": [
+ {
+ "path": "../../../packages/core/"
+ },
+ {
+ "path": "../../../packages/react/"
+ }
+ ]
+}
diff --git a/examples/06-custom-schema/12-container-table/vite-env.d.ts b/examples/06-custom-schema/12-container-table/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/examples/06-custom-schema/12-container-table/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/06-custom-schema/12-container-table/vite.config.ts b/examples/06-custom-schema/12-container-table/vite.config.ts
new file mode 100644
index 0000000000..a96f1f04ff
--- /dev/null
+++ b/examples/06-custom-schema/12-container-table/vite.config.ts
@@ -0,0 +1,35 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import react from "@vitejs/plugin-react";
+import * as fs from "fs";
+import * as path from "path";
+import { defineConfig } from "vite";
+// https://vitejs.dev/config/
+export default defineConfig(((conf: { command: string }) => ({
+ plugins: [react()],
+ optimizeDeps: {},
+ build: {
+ sourcemap: true,
+ },
+ resolve: {
+ alias:
+ conf.command === "build" ||
+ !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ ? {}
+ : ({
+ // The repo-wide alias for the shared test-utils directory (private,
+ // so it only resolves inside the monorepo). Harmless for examples
+ // that don't use it.
+ "@shared": path.resolve(__dirname, "../../../shared/"),
+ // Comment out the lines below to load a built version of blocknote
+ // or, keep as is to load live from sources with live reload working
+ "@blocknote/core": path.resolve(
+ __dirname,
+ "../../packages/core/src/",
+ ),
+ "@blocknote/react": path.resolve(
+ __dirname,
+ "../../packages/react/src/",
+ ),
+ } as any),
+ },
+})) as Parameters[0]);
diff --git a/packages/core/package.json b/packages/core/package.json
index eb2700636d..8b9f1ee69b 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -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",
diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts
index b41b268617..7f57dfbe9c 100644
--- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts
@@ -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";
@@ -8,10 +8,83 @@ import {
InlineContentSchema,
StyleSchema,
} from "../../../../schema/index.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 { descendToInsertionPos } from "../../containers/containerNav.js";
+
+/**
+ * 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" | "first-child" | "last-child";
+
+/**
+ * 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);
+
+ // `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;
+ }
+
+ 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 === undefined ? null : { pos };
+ }
+
+ // 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"];
+
+ return info.hasContent && blockGroupType?.contentMatch.matchType(nodeType)
+ ? { pos: info.content.afterPos, wrapIn: blockGroupType }
+ : null;
+}
export function insertBlocks<
BSchema extends BlockSchema,
@@ -21,7 +94,7 @@ export function insertBlocks<
tr: Transaction,
blocksToInsert: PartialBlock[],
referenceBlock: BlockIdentifier,
- placement: "before" | "after" = "before",
+ placement: BlockPlacement = "before",
): Block[] {
const id =
typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id;
@@ -37,14 +110,57 @@ 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 [];
+ }
+
+ function reject(): never {
+ const what =
+ nodesToInsert.length === 1
+ ? `a block of type "${blocksToInsert[0].type ?? "paragraph"}"`
+ : `${nodesToInsert.length} blocks`;
+ const them = nodesToInsert.length === 1 ? "it" : "them";
+
+ throw new Error(
+ `Cannot insert ${what} ` +
+ (placement === "before" || placement === "after"
+ ? `${placement} block with ID ${id}: its parent does not accept ${them}.`
+ : `as the ${placement} of block with ID ${id}: the block does not accept ${them} as ${nodesToInsert.length === 1 ? "a child" : "children"}.`),
+ );
}
- 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) {
+ reject();
+ }
+
+ // `getInsertionPos` can only answer for the first node's type: the fragment
+ // doesn't exist yet when it runs. The whole fragment still has to fit — a
+ // `max: 1` container accepts one paragraph but not three — so it is checked
+ // here, where the nodes are known, rather than left to `tr.step` to reject
+ // with a ProseMirror-level message.
+ if (
+ target.wrapIn &&
+ !target.wrapIn.validContent(Fragment.from(nodesToInsert))
+ ) {
+ reject();
+ }
+
+ const fragment = target.wrapIn
+ ? Fragment.from(target.wrapIn.create(null, nodesToInsert))
+ : Fragment.from(nodesToInsert);
+
+ const $target = tr.doc.resolve(target.pos);
+ if (!$target.parent.canReplace($target.index(), $target.index(), fragment)) {
+ reject();
+ }
+
+ 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.
diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts
new file mode 100644
index 0000000000..7bf8e4f137
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts
@@ -0,0 +1,262 @@
+// @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) =>
+ createBlockSpec({ type, propSchema: {}, ...config } as any, {
+ render: () => {
+ throw new Error("not rendered in this suite");
+ },
+ })();
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ // 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 },
+ }),
+ // 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 },
+ }),
+ // Same, but legally empty: room for the first of a batch, but not for
+ // the batch.
+ emptySingle: container("emptySingle", {
+ content: "none",
+ children: { allow: "any", min: 0, max: 1 },
+ }),
+ } as const,
+});
+
+let editor: BlockNoteEditor;
+
+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 "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",
+ "first-child",
+ );
+ editor.insertBlocks(
+ [{ id: "last", type: "paragraph" }],
+ "b-0",
+ "last-child",
+ );
+
+ 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",
+ "first-child",
+ );
+ editor.insertBlocks(
+ [{ id: "last", type: "paragraph" }],
+ "b-0",
+ "last-child",
+ );
+
+ expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([
+ "first",
+ "existing",
+ "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",
+ "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(
+ "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",
+ "last-child",
+ );
+ editor.insertBlocks(
+ [{ id: "first", type: "paragraph" }],
+ "p-0",
+ "first-child",
+ );
+
+ 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", "last-child"),
+ ).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/);
+ });
+
+ it("throws when only the first of several blocks would fit", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "e-0", type: "emptySingle", children: [] },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ // The container is empty and takes one child, so the first paragraph
+ // fits. Validating only that one used to let the batch through and fail
+ // later with a raw ProseMirror `ReplaceError`.
+ expect(() =>
+ editor.insertBlocks(
+ [{ type: "paragraph" }, { type: "paragraph" }],
+ "e-0",
+ "last-child",
+ ),
+ ).toThrow(/does not accept them as children/);
+
+ expect(editor.getBlock("e-0")!.children).toEqual([]);
+ });
+
+ it("still inserts a batch that fits in full", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "b-0", type: "box", children: [] },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.insertBlocks(
+ [
+ { id: "one", type: "paragraph" },
+ { id: "two", type: "paragraph" },
+ ],
+ "b-0",
+ "last-child",
+ );
+
+ expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([
+ "one",
+ "two",
+ ]);
+ });
+});
diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts
index ebe8ae9eff..532da5aaef 100644
--- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts
@@ -2,7 +2,10 @@ 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 { getNodeById } from "../../../nodeUtil.js";
+import { containerSchema } from "../../containers/containers.fixture.js";
+import { mergeBlocksCommand } from "./mergeBlocks.js";
const getEditor = setupTestEnv();
@@ -14,7 +17,7 @@ function mergeBlocks(posBetweenBlocks: number) {
function getPosBeforeSelectedBlock() {
return getEditor().transact(
- (tr) => getBlockInfoFromSelection(tr).bnBlock.beforePos,
+ (tr) => getBlockInfoFromSelection(tr).block.beforePos,
);
}
@@ -145,3 +148,87 @@ describe("Test mergeBlocks", () => {
expect(ret).toBeFalsy();
});
});
+
+describe("Test mergeBlocks at container boundaries", () => {
+ const getContainerEditor = setupTestEnv({
+ schema: containerSchema,
+ document: [
+ { id: "before-callout", type: "paragraph", content: "Before callout" },
+ {
+ id: "callout-0",
+ type: "callout",
+ children: [
+ {
+ id: "callout-child-0",
+ type: "paragraph",
+ content: "Callout child 0",
+ },
+ {
+ id: "callout-child-1",
+ type: "paragraph",
+ content: "Callout child 1",
+ },
+ ],
+ },
+ { id: "after-callout", type: "paragraph", content: "After callout" },
+ ],
+ });
+
+ function mergeContainerBlocks(posBetweenBlocks: number) {
+ return getContainerEditor()._tiptapEditor.commands.command(
+ mergeBlocksCommand(posBetweenBlocks),
+ );
+ }
+
+ function getPosBefore(id: string) {
+ return getContainerEditor().transact((tr) => {
+ const node = getNodeById(id, tr.doc);
+ if (!node) {
+ throw new Error(`No block with id "${id}" in the test document`);
+ }
+ return node.posBeforeNode;
+ });
+ }
+
+ // A container's first child has no previous sibling, so there is nothing to
+ // merge it into. The block above it on screen sits outside the container.
+ it("Does not merge a container's first child out of the container", () => {
+ const originalDocument = getContainerEditor().document;
+ const ret = mergeContainerBlocks(getPosBefore("callout-child-0"));
+
+ expect(ret).toBeFalsy();
+ expect(getContainerEditor().document).toEqual(originalDocument);
+ });
+
+ // A container has no content of its own, so there is nothing to merge.
+ it("Does not merge a container into the block above it", () => {
+ const originalDocument = getContainerEditor().document;
+ const ret = mergeContainerBlocks(getPosBefore("callout-0"));
+
+ expect(ret).toBeFalsy();
+ expect(getContainerEditor().document).toEqual(originalDocument);
+ });
+
+ // `mergeBlocksCommand` treats a container like any other block with children
+ // and merges into its last descendant, which puts the merged text inside the
+ // container. Backspace never produces this, because
+ // `KeyboardShortcutsExtension` bails out when the previous sibling has no
+ // inline content and moves the block into the container instead. So this is
+ // the command's behaviour on its own, not the editor's; it is pinned here
+ // because `mergeBlocks.ts` documents the opposite.
+ it("Merges a block into the last descendant of the container above it", () => {
+ const ret = mergeContainerBlocks(getPosBefore("after-callout"));
+
+ expect(ret).toBeTruthy();
+
+ const document = getContainerEditor().document;
+
+ expect(document.map((block) => block.id)).toEqual([
+ "before-callout",
+ "callout-0",
+ ]);
+ expect(document[1].children[1].content).toEqual([
+ { type: "text", text: "Callout child 1After callout", styles: {} },
+ ]);
+ });
+});
diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
index ce1a9455db..c9ffb6003d 100644
--- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
@@ -1,170 +1,11 @@
-import { Node } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import {
- BlockInfo,
- getBlockInfoFromResolvedPos,
+ getBlockInfoAt,
+ getLastDescendantBlockInfo,
+ getPrevBlockInfo,
} from "../../../getBlockInfoFromPos.js";
-/**
- * Returns the block info from the parent block
- * or undefined if we're at the root
- */
-export const getParentBlockInfo = (
- doc: Node,
- beforePos: number,
-): BlockInfo | undefined => {
- const $pos = doc.resolve(beforePos);
- const depth = $pos.depth - 1;
-
- if (depth < 1) {
- return undefined;
- }
-
- const parentBeforePos = $pos.before(depth);
- const parentNode = doc.resolve(parentBeforePos).nodeAfter;
-
- if (!parentNode) {
- return undefined;
- }
-
- if (!parentNode.type.spec.group?.includes("bnBlock")) {
- return getParentBlockInfo(doc, parentBeforePos);
- }
-
- const parentBlockInfo = getBlockInfoFromResolvedPos(
- doc.resolve(parentBeforePos),
- );
-
- return parentBlockInfo;
-};
-
-/**
- * Returns the block info from the sibling block before (above) the given block,
- * or undefined if the given block is the first sibling.
- */
-export const getPrevBlockInfo = (doc: Node, beforePos: number) => {
- const $pos = doc.resolve(beforePos);
-
- const indexInParent = $pos.index();
-
- if (indexInParent === 0) {
- return undefined;
- }
-
- const prevBlockBeforePos = $pos.posAtIndex(indexInParent - 1);
-
- const prevBlockInfo = getBlockInfoFromResolvedPos(
- doc.resolve(prevBlockBeforePos),
- );
- return prevBlockInfo;
-};
-
-/**
- * Returns the block info from the sibling block after (below) the given block,
- * or undefined if the given block is the last sibling.
- */
-export const getNextBlockInfo = (doc: Node, beforePos: number) => {
- const $pos = doc.resolve(beforePos);
-
- const indexInParent = $pos.index();
-
- if (indexInParent === $pos.node().childCount - 1) {
- return undefined;
- }
-
- const nextBlockBeforePos = $pos.posAtIndex(indexInParent + 1);
-
- const nextBlockInfo = getBlockInfoFromResolvedPos(
- doc.resolve(nextBlockBeforePos),
- );
- return nextBlockInfo;
-};
-
-/**
- * If a block has children like this:
- * A
- * - B
- * - C
- * -- D
- *
- * Then the bottom nested block returned is D.
- */
-export const getBottomNestedBlockInfo = (doc: Node, blockInfo: BlockInfo) => {
- while (blockInfo.childContainer) {
- const group = blockInfo.childContainer.node;
-
- const newPos = doc
- .resolve(blockInfo.childContainer.beforePos + 1)
- .posAtIndex(group.childCount - 1);
- blockInfo = getBlockInfoFromResolvedPos(doc.resolve(newPos));
- }
-
- return blockInfo;
-};
-
-const canMerge = (prevBlockInfo: BlockInfo, nextBlockInfo: BlockInfo) => {
- return (
- prevBlockInfo.isBlockContainer &&
- prevBlockInfo.blockContent.node.type.spec.content === "inline*" &&
- prevBlockInfo.blockContent.node.childCount > 0 &&
- nextBlockInfo.isBlockContainer &&
- nextBlockInfo.blockContent.node.type.spec.content === "inline*"
- );
-};
-
-const mergeBlocks = (
- state: EditorState,
- dispatch: ((args?: any) => any) | undefined,
- prevBlockInfo: BlockInfo,
- nextBlockInfo: BlockInfo,
-) => {
- // Un-nests all children of the next block.
- if (!nextBlockInfo.isBlockContainer) {
- throw new Error(
- `Attempted to merge block at position ${nextBlockInfo.bnBlock.beforePos} into previous block at position ${prevBlockInfo.bnBlock.beforePos}, but next block is not a block container`,
- );
- }
-
- // Removes a level of nesting all children of the next block by 1 level, if it contains both content and block
- // group nodes.
- if (nextBlockInfo.childContainer) {
- const childBlocksStart = state.doc.resolve(
- nextBlockInfo.childContainer.beforePos + 1,
- );
- const childBlocksEnd = state.doc.resolve(
- nextBlockInfo.childContainer.afterPos - 1,
- );
- const childBlocksRange = childBlocksStart.blockRange(childBlocksEnd);
-
- if (dispatch) {
- const pos = state.doc.resolve(nextBlockInfo.bnBlock.beforePos);
- state.tr.lift(childBlocksRange!, pos.depth);
- }
- }
-
- // Deletes the boundary between the two blocks. Can be thought of as
- // removing the closing tags of the first block and the opening tags of the
- // second one to stitch them together.
- if (dispatch) {
- if (!prevBlockInfo.isBlockContainer) {
- throw new Error(
- `Attempted to merge block at position ${nextBlockInfo.bnBlock.beforePos} into previous block at position ${prevBlockInfo.bnBlock.beforePos}, but previous block is not a block container`,
- );
- }
-
- // TODO: test merging between a columnList and paragraph, between two columnLists, and v.v.
- dispatch(
- state.tr.delete(
- prevBlockInfo.blockContent.afterPos - 1,
- nextBlockInfo.blockContent.beforePos + 1,
- ),
- );
- }
-
- return true;
-};
-
export const mergeBlocksCommand =
(posBetweenBlocks: number) =>
({
@@ -174,26 +15,75 @@ export const mergeBlocksCommand =
state: EditorState;
dispatch: ((args?: any) => any) | undefined;
}) => {
- const $pos = state.doc.resolve(posBetweenBlocks);
- const nextBlockInfo = getBlockInfoFromResolvedPos($pos);
+ const nextBlockInfo = getBlockInfoAt(state.doc, posBetweenBlocks);
const prevBlockInfo = getPrevBlockInfo(
state.doc,
- nextBlockInfo.bnBlock.beforePos,
+ nextBlockInfo.block.beforePos,
);
if (!prevBlockInfo) {
return false;
}
- const bottomNestedBlockInfo = getBottomNestedBlockInfo(
+ // The block we merge into is the last descendant of the previous block:
+ // visually, that's the block directly above the boundary.
+ const bottomNestedBlockInfo = getLastDescendantBlockInfo(
state.doc,
prevBlockInfo,
);
- if (!canMerge(bottomNestedBlockInfo, nextBlockInfo)) {
+ // Only inline-content blocks can merge, and merging into an empty block
+ // is handled elsewhere (by deleting the empty block instead). Merging
+ // into or out of container blocks (columnLists, callouts, ...) is
+ // intentionally unsupported; the container-boundary Backspace/Delete
+ // branches in `KeyboardShortcutsExtension` handle those cases by moving
+ // blocks across the boundary instead of merging their content.
+ if (
+ !bottomNestedBlockInfo.hasContent ||
+ bottomNestedBlockInfo.contentKind !== "inline" ||
+ bottomNestedBlockInfo.isContentEmpty ||
+ !nextBlockInfo.hasContent ||
+ nextBlockInfo.contentKind !== "inline"
+ ) {
return false;
}
- return mergeBlocks(state, dispatch, bottomNestedBlockInfo, nextBlockInfo);
+ // Un-nests the next block's children by one level, so they survive as
+ // siblings of the merged block rather than as children of a block that no
+ // longer exists once the boundary below is deleted.
+ //
+ // Note `state.tr` is tiptap's chainable state, whose getter returns the one
+ // transaction shared by the command chain (not a fresh `Transaction` like
+ // `EditorState.tr`), so this lift carries over into the `dispatch` below.
+ if (dispatch && nextBlockInfo.children) {
+ const childBlocksRange = state.doc
+ .resolve(nextBlockInfo.children.childrenStart)
+ .blockRange(state.doc.resolve(nextBlockInfo.children.childrenEnd));
+
+ if (!childBlocksRange) {
+ throw new Error(
+ "Children of a block are expected to form a block range",
+ );
+ }
+
+ state.tr.lift(
+ childBlocksRange,
+ state.doc.resolve(nextBlockInfo.block.beforePos).depth,
+ );
+ }
+
+ // Deletes the boundary between the two blocks. Can be thought of as
+ // removing the closing tags of the first block and the opening tags of the
+ // second one to stitch them together.
+ if (dispatch) {
+ dispatch(
+ state.tr.delete(
+ bottomNestedBlockInfo.contentEnd,
+ nextBlockInfo.contentStart,
+ ),
+ );
+ }
+
+ return true;
};
diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts
index 61964a49ee..f9bba17c3f 100644
--- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts
@@ -3,7 +3,7 @@ import { CellSelection } from "prosemirror-tables";
import { describe, expect, it } from "vite-plus/test";
import {
- getBlockInfoAtNearest,
+ getBlockInfoNearPos,
getBlockInfoFromSelection,
getNodeId,
} from "../../../getBlockInfoFromPos.js";
@@ -18,12 +18,12 @@ const getEditor = setupTestEnv();
function makeSelectionSpanContent(selectionType: "text" | "node" | "cell") {
const blockInfo = getEditor().transact((tr) => getBlockInfoFromSelection(tr));
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.hasContent) {
throw new Error(
`Selection points to a ${blockInfo.blockNoteType} node, not a blockContainer node`,
);
}
- const { blockContent } = blockInfo;
+ const { content } = blockInfo;
const editor = getEditor();
if (selectionType === "cell") {
@@ -31,22 +31,22 @@ function makeSelectionSpanContent(selectionType: "text" | "node" | "cell") {
tr.setSelection(
CellSelection.create(
tr.doc,
- tr.doc.resolve(blockContent.beforePos + 3).before(),
- tr.doc.resolve(blockContent.afterPos - 3).before(),
+ tr.doc.resolve(content.beforePos + 3).before(),
+ tr.doc.resolve(content.afterPos - 3).before(),
),
),
);
} else if (selectionType === "node") {
editor.transact((tr) =>
- tr.setSelection(NodeSelection.create(tr.doc, blockContent.beforePos)),
+ tr.setSelection(NodeSelection.create(tr.doc, content.beforePos)),
);
} else {
editor.transact((tr) =>
tr.setSelection(
TextSelection.create(
tr.doc,
- blockContent.beforePos + 1,
- blockContent.afterPos - 1,
+ content.beforePos + 1,
+ content.afterPos - 1,
),
),
);
@@ -223,11 +223,11 @@ describe("Test moveBlocksUp", () => {
const { anchorBlockId, headBlockId } = getEditor().transact((tr) => ({
anchorBlockId: getNodeId(
- getBlockInfoAtNearest(tr, tr.selection.anchor).bnBlock.node,
+ getBlockInfoNearPos(tr, tr.selection.anchor).block.node,
tr.doc,
),
headBlockId: getNodeId(
- getBlockInfoAtNearest(tr, tr.selection.head).bnBlock.node,
+ getBlockInfoNearPos(tr, tr.selection.head).block.node,
tr.doc,
),
}));
@@ -347,11 +347,11 @@ describe("Test moveBlocksDown", () => {
const { anchorBlockId, headBlockId } = getEditor().transact((tr) => ({
anchorBlockId: getNodeId(
- getBlockInfoAtNearest(tr, tr.selection.anchor).bnBlock.node,
+ getBlockInfoNearPos(tr, tr.selection.anchor).block.node,
tr.doc,
),
headBlockId: getNodeId(
- getBlockInfoAtNearest(tr, tr.selection.head).bnBlock.node,
+ getBlockInfoNearPos(tr, tr.selection.head).block.node,
tr.doc,
),
}));
diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts
index 71598b7d69..267a23b27c 100644
--- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts
@@ -1,3 +1,4 @@
+import type { Node, Schema } from "prosemirror-model";
import {
NodeSelection,
Selection,
@@ -10,13 +11,43 @@ import { Block } from "../../../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor";
import { BlockIdentifier } from "../../../../schema/index.js";
import {
- getBlockInfoAtNearest,
+ isBlockGroupInsertable,
+ isContainerNode,
+ isSealed,
+} from "../../../../schema/blocks/children.js";
+import {
+ getBlockInfoNearPos,
getNodeId,
} from "../../../getBlockInfoFromPos.js";
import { getNodeById } from "../../../nodeUtil.js";
-import { insertBlocks } from "../insertBlocks/insertBlocks.js";
+import { getInsertionPos, insertBlocks } from "../insertBlocks/insertBlocks.js";
import { removeAndInsertBlocks } from "../replaceBlocks/replaceBlocks.js";
+/**
+ * Dissolves `placement: "containerOnly"` blocks into their children.
+ *
+ * A `containerOnly` block (a `column`, say) is defined only in terms of the
+ * container that holds it, so it can't land anywhere a regular block goes —
+ * moving one out of its container moves its children instead. Every other
+ * block passes through as itself.
+ */
+function dissolveContainerOnlyBlocks(
+ blocks: Block[],
+ pmSchema: Schema,
+): Block[] {
+ return blocks.flatMap((block) => {
+ const nodeType = pmSchema.nodes[block.type];
+ // A container denied the `blockGroupChild` group is one declared
+ // `placement: "containerOnly"`.
+ const isContainerOnly =
+ isContainerNode(nodeType) && !isBlockGroupInsertable(nodeType);
+
+ return isContainerOnly
+ ? dissolveContainerOnlyBlocks(block.children, pmSchema)
+ : [block];
+ });
+}
+
type BlockSelectionData = (
| {
type: "text";
@@ -49,18 +80,18 @@ function getBlockSelectionData(
editor: BlockNoteEditor,
): BlockSelectionData {
return editor.transact((tr) => {
- const anchorBlockPosInfo = getBlockInfoAtNearest(tr, tr.selection.anchor);
+ const anchorBlockPosInfo = getBlockInfoNearPos(tr, tr.selection.anchor);
- const anchorBlockId = getNodeId(anchorBlockPosInfo.bnBlock.node, tr.doc);
+ const anchorBlockId = getNodeId(anchorBlockPosInfo.block.node, tr.doc);
if (tr.selection instanceof CellSelection) {
return {
type: "cell" as const,
anchorBlockId,
anchorCellOffset:
- tr.selection.$anchorCell.pos - anchorBlockPosInfo.bnBlock.beforePos,
+ tr.selection.$anchorCell.pos - anchorBlockPosInfo.block.beforePos,
headCellOffset:
- tr.selection.$headCell.pos - anchorBlockPosInfo.bnBlock.beforePos,
+ tr.selection.$headCell.pos - anchorBlockPosInfo.block.beforePos,
};
} else if (tr.selection instanceof NodeSelection) {
return {
@@ -68,15 +99,14 @@ function getBlockSelectionData(
anchorBlockId,
};
} else {
- const headBlockPosInfo = getBlockInfoAtNearest(tr, tr.selection.head);
+ const headBlockPosInfo = getBlockInfoNearPos(tr, tr.selection.head);
return {
type: "text" as const,
anchorBlockId,
- headBlockId: getNodeId(headBlockPosInfo.bnBlock.node, tr.doc),
- anchorOffset:
- tr.selection.anchor - anchorBlockPosInfo.bnBlock.beforePos,
- headOffset: tr.selection.head - headBlockPosInfo.bnBlock.beforePos,
+ headBlockId: getNodeId(headBlockPosInfo.block.node, tr.doc),
+ anchorOffset: tr.selection.anchor - anchorBlockPosInfo.block.beforePos,
+ headOffset: tr.selection.head - headBlockPosInfo.block.beforePos,
};
}
});
@@ -131,16 +161,6 @@ function updateBlockSelectionFromData(
tr.setSelection(selection);
}
-// Replaces top-level `column` blocks with their children, as a `column` is not
-// a valid block outside a `columnList`. Other blocks are returned as-is.
-function flattenColumns(
- blocks: Block[],
-): Block[] {
- return blocks.flatMap((block) =>
- block.type === "column" ? block.children : [block],
- );
-}
-
/**
* Removes the given blocks from the editor, then inserts them before/after a
* reference block.
@@ -169,10 +189,10 @@ export function moveBlocks(
//
// When the non-empty block is moved up, the column is seen as empty and
// collapsed in the removal step, so the following insertion fails.
- removeAndInsertBlocks(tr, blocks, [], { fixColumns: false });
+ removeAndInsertBlocks(tr, blocks, [], { fixContainers: false });
insertBlocks(
tr,
- flattenColumns(blocks),
+ dissolveContainerOnlyBlocks(blocks, editor.pmSchema),
referenceBlock,
placement,
);
@@ -207,12 +227,66 @@ export function moveSelectedBlocksAndSelection(
});
}
-// Checks if a block is in a valid place after being moved. This check is
-// primitive at the moment and only returns false if the block's parent is a
-// `columnList` block. This is because regular blocks cannot be direct children
-// of `columnList` blocks.
-function checkPlacementIsValid(parentBlock?: Block): boolean {
- return !parentBlock || parentBlock.type !== "columnList";
+// The nearest sealed container a position sits in, or `undefined` if there
+// isn't one.
+function sealedAncestorId(doc: Node, pos: number): string | undefined {
+ const $pos = doc.resolve(pos);
+
+ for (let depth = $pos.depth; depth > 0; depth--) {
+ const ancestor = $pos.node(depth);
+ if (isSealed(ancestor)) {
+ return ancestor.attrs.id;
+ }
+ }
+
+ return undefined;
+}
+
+// Checks if a regular block would be in a valid place after being moved
+// before/after `referenceBlock`. A regular block nests under any non-container
+// block (it goes into that block's `blockGroup`), but a container block (e.g. a
+// `columnList`) only accepts what its content expression allows.
+//
+// Deferred to `getInsertionPos` so that "can a block go here?" has exactly
+// one answer, shared with `insertBlocks`, and comes from the schema rather
+// than from a rule restated here.
+function checkPlacementIsValid(
+ editor: BlockNoteEditor,
+ referenceBlock: Block,
+ placement: "before" | "after",
+ movedBlock: Block,
+): boolean {
+ // The PM node type to validate the destination against: the first block
+ // `moveBlocks` would actually insert, which is `movedBlock` itself unless it
+ // dissolves. A container (e.g. a `callout`) is inserted as its own node
+ // type; anything else goes in as a generic `blockContainer` wrapper.
+ const first = dissolveContainerOnlyBlocks([movedBlock], editor.pmSchema)[0];
+ const firstType = first ? editor.pmSchema.nodes[first.type] : undefined;
+ const nodeType =
+ firstType && isContainerNode(firstType)
+ ? firstType
+ : editor.pmSchema.nodes["blockContainer"];
+
+ return editor.transact((tr) => {
+ const posInfo = getNodeById(referenceBlock.id, tr.doc);
+ const movedPosInfo = getNodeById(movedBlock.id, tr.doc);
+ if (!posInfo || !movedPosInfo) {
+ return false;
+ }
+
+ const target = getInsertionPos(tr.doc, posInfo, placement, nodeType);
+ if (!target) {
+ return false;
+ }
+
+ // Moving is a gesture, so it can't take a block across a seal: the block
+ // and its destination have to sit inside the same sealed container (or
+ // outside any of them).
+ return (
+ sealedAncestorId(tr.doc, target.pos) ===
+ sealedAncestorId(tr.doc, movedPosInfo.posBeforeNode)
+ );
+ });
}
// Gets the placement for moving a block up. This has 3 cases:
@@ -227,6 +301,7 @@ function checkPlacementIsValid(parentBlock?: Block): boolean {
// the block is already at the top of the document.
function getMoveUpPlacement(
editor: BlockNoteEditor,
+ movedBlock: Block,
prevBlock?: Block,
parentBlock?: Block,
):
@@ -253,10 +328,11 @@ function getMoveUpPlacement(
return undefined;
}
- const referenceBlockParent = editor.getParentBlock(referenceBlock);
- if (!checkPlacementIsValid(referenceBlockParent)) {
+ if (!checkPlacementIsValid(editor, referenceBlock, placement, movedBlock)) {
+ const referenceBlockParent = editor.getParentBlock(referenceBlock);
return getMoveUpPlacement(
editor,
+ movedBlock,
placement === "after"
? referenceBlock
: editor.getPrevBlock(referenceBlock),
@@ -279,6 +355,7 @@ function getMoveUpPlacement(
// the block is already at the bottom of the document.
function getMoveDownPlacement(
editor: BlockNoteEditor,
+ movedBlock: Block,
nextBlock?: Block,
parentBlock?: Block,
):
@@ -305,10 +382,11 @@ function getMoveDownPlacement(
return undefined;
}
- const referenceBlockParent = editor.getParentBlock(referenceBlock);
- if (!checkPlacementIsValid(referenceBlockParent)) {
+ if (!checkPlacementIsValid(editor, referenceBlock, placement, movedBlock)) {
+ const referenceBlockParent = editor.getParentBlock(referenceBlock);
return getMoveDownPlacement(
editor,
+ movedBlock,
placement === "before"
? referenceBlock
: editor.getNextBlock(referenceBlock),
@@ -338,6 +416,7 @@ export function moveBlocksUp(
const moveUpPlacement = getMoveUpPlacement(
editor,
+ sourceBlock,
editor.getPrevBlock(sourceBlock),
editor.getParentBlock(sourceBlock),
);
@@ -369,20 +448,28 @@ export function moveBlocksDown(
) {
editor.transact(() => {
let sourceBlock: Block | undefined;
+ // The block whose position anchors the move (the last of a selection when
+ // moving down) vs. the first block that gets inserted, which is what the
+ // placement check must validate against.
+ let firstMovedBlock: Block | undefined;
if (blockIdentifier) {
sourceBlock = editor.getBlock(blockIdentifier);
if (!sourceBlock) {
return;
}
+ firstMovedBlock = sourceBlock;
} else {
const selection = editor.getSelection();
sourceBlock =
selection?.blocks[selection?.blocks.length - 1] ||
editor.getTextCursorPosition().block;
+ firstMovedBlock =
+ selection?.blocks[0] || editor.getTextCursorPosition().block;
}
const moveDownPlacement = getMoveDownPlacement(
editor,
+ firstMovedBlock,
editor.getNextBlock(sourceBlock),
editor.getParentBlock(sourceBlock),
);
diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts
index 8247e9391c..da69161218 100644
--- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
import { afterAll, beforeAll } from "vite-plus/test";
import { PartialBlock } from "../../../../blocks/defaultBlocks.js";
import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
+import { containerSchema } from "../../containers/containers.fixture.js";
/**
* Custom test setup with a document designed to reproduce nesting/unnesting bugs.
@@ -646,6 +647,132 @@ describe("unnestBlock / liftListItem", () => {
});
});
+// A second editor, on a schema that has container blocks. `setupNestTestEnv`
+// builds a default-schema editor, which can't express any of the cases below.
+function setupContainerNestTestEnv() {
+ let editor: BlockNoteEditor;
+ const div = document.createElement("div");
+
+ beforeAll(() => {
+ editor = BlockNoteEditor.create({ schema: containerSchema });
+ editor.mount(div);
+ });
+
+ afterAll(() => {
+ editor._tiptapEditor.destroy();
+ editor = undefined as any;
+ });
+
+ return (doc: PartialBlock[]) => {
+ editor.replaceBlocks(editor.document, doc);
+ return editor;
+ };
+}
+
+// `canNestBlock` and `canUnnestBlock` run the real command on a transaction
+// that is thrown away, rather than restating its preconditions. The cases here
+// are the ones where the old, restated preconditions gave the wrong answer:
+// they looked at a previous sibling's mere existence and at the block's depth,
+// neither of which knows anything about containers.
+describe("canNestBlock / canUnnestBlock around containers", () => {
+ const withContainerEditor = setupContainerNestTestEnv();
+
+ it("Reports that a block cannot be nested under a container sibling", () => {
+ const editor = withContainerEditor([
+ {
+ id: "callout-0",
+ type: "callout",
+ children: [
+ { id: "callout-child", type: "paragraph", content: "Callout child" },
+ ],
+ },
+ { id: "paragraph-0", type: "paragraph", content: "Paragraph 0" },
+ ]);
+
+ editor.setTextCursorPosition("paragraph-0", "start");
+
+ const before = editor.document;
+ expect(editor.canNestBlock()).toBe(false);
+
+ // And the answer matches what nesting actually does.
+ editor.nestBlock();
+ expect(editor.document).toEqual(before);
+ });
+
+ it("Reports that a container's child cannot be unnested out of it", () => {
+ const editor = withContainerEditor([
+ {
+ id: "callout-0",
+ type: "callout",
+ children: [
+ { id: "callout-child", type: "paragraph", content: "Callout child" },
+ ],
+ },
+ ]);
+
+ editor.setTextCursorPosition("callout-child", "start");
+
+ const before = editor.document;
+ expect(editor.canUnnestBlock()).toBe(false);
+
+ editor.unnestBlock();
+ expect(editor.document).toEqual(before);
+ });
+
+ it("Reports that a block with a plain previous sibling can be nested", () => {
+ const editor = withContainerEditor([
+ { id: "paragraph-0", type: "paragraph", content: "Paragraph 0" },
+ { id: "paragraph-1", type: "paragraph", content: "Paragraph 1" },
+ ]);
+
+ editor.setTextCursorPosition("paragraph-1", "start");
+
+ const before = editor.document;
+ expect(editor.canNestBlock()).toBe(true);
+ // The probe runs the command on a transaction it never dispatches, so
+ // answering must not change the document.
+ expect(editor.document).toEqual(before);
+
+ editor.nestBlock();
+ expect(editor.getBlock("paragraph-0")!.children.map((c) => c.id)).toEqual([
+ "paragraph-1",
+ ]);
+ expect(editor.canUnnestBlock()).toBe(true);
+ });
+
+ it("Nests and unnests a block inside a container's children", () => {
+ const editor = withContainerEditor([
+ {
+ id: "callout-0",
+ type: "callout",
+ children: [
+ { id: "child-0", type: "paragraph", content: "Child 0" },
+ { id: "child-1", type: "paragraph", content: "Child 1" },
+ ],
+ },
+ ]);
+
+ const before = editor.document;
+
+ editor.setTextCursorPosition("child-1", "start");
+ expect(editor.canNestBlock()).toBe(true);
+ editor.nestBlock();
+
+ expect(editor.getBlock("callout-0")!.children.map((c) => c.id)).toEqual([
+ "child-0",
+ ]);
+ expect(editor.getBlock("child-0")!.children.map((c) => c.id)).toEqual([
+ "child-1",
+ ]);
+
+ editor.setTextCursorPosition("child-1", "start");
+ expect(editor.canUnnestBlock()).toBe(true);
+ editor.unnestBlock();
+
+ expect(editor.document).toEqual(before);
+ });
+});
+
/** Recursively collects all block IDs from a document */
function flattenBlockIds(blocks: any[]): string[] {
const ids: string[] = [];
diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
index a0f76fdff0..b7b091b8f4 100644
--- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
@@ -3,7 +3,6 @@ import { Transaction } from "prosemirror-state";
import { canJoin, liftTarget, ReplaceAroundStep } from "prosemirror-transform";
import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
-import { getBlockInfoFromSelection } from "../../../getBlockInfoFromPos.js";
/**
* Modified version of prosemirror-schema-list's sinkItem.
@@ -19,9 +18,7 @@ function sinkItem(tr: Transaction, itemType: NodeType, groupType: NodeType) {
const { $from, $to } = tr.selection;
const range = $from.blockRange(
$to,
- (node) =>
- node.childCount > 0 &&
- (node.type.name === "blockGroup" || node.type.name === "column"), // change 1
+ (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), // change 1
);
if (!range) {
return false;
@@ -64,14 +61,17 @@ function sinkItem(tr: Transaction, itemType: NodeType, groupType: NodeType) {
return true;
}
-export function nestBlock(editor: BlockNoteEditor) {
- return editor.transact((tr) => {
- return sinkItem(
+function nestCommand(editor: BlockNoteEditor) {
+ return (tr: Transaction) =>
+ sinkItem(
tr,
editor.pmSchema.nodes["blockContainer"],
editor.pmSchema.nodes["blockGroup"],
);
- });
+}
+
+export function nestBlock(editor: BlockNoteEditor) {
+ return editor.transact(nestCommand(editor));
}
/**
@@ -163,9 +163,7 @@ export function liftItem(
const { $from, $to } = tr.selection;
const range = $from.blockRange(
$to,
- (node) =>
- node.childCount > 0 &&
- (node.type.name === "blockGroup" || node.type.name === "column"), // change 1
+ (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), // change 1
);
if (!range) {
return false;
@@ -181,28 +179,28 @@ export function liftItem(
return false;
}
-export function unnestBlock(editor: BlockNoteEditor) {
- return editor.transact((tr) =>
+function unnestCommand(editor: BlockNoteEditor) {
+ return (tr: Transaction) =>
liftItem(
tr,
editor.pmSchema.nodes["blockContainer"],
editor.pmSchema.nodes["blockGroup"],
- ),
- );
+ );
}
-export function canNestBlock(editor: BlockNoteEditor) {
- return editor.transact((tr) => {
- const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr);
+export function unnestBlock(editor: BlockNoteEditor) {
+ return editor.transact(unnestCommand(editor));
+}
- return tr.doc.resolve(blockContainer.beforePos).nodeBefore !== null;
- });
+// `canExec` hands the command a transaction it never dispatches, so "can I
+// nest?" is answered by nesting and throwing the result away. A second
+// statement of the preconditions would drift from the command it describes —
+// and did: it read a previous sibling's mere existence, so a container block
+// before the cursor enabled the button while `nestBlock` did nothing.
+export function canNestBlock(editor: BlockNoteEditor) {
+ return editor.canExec((state) => nestCommand(editor)(state.tr));
}
export function canUnnestBlock(editor: BlockNoteEditor) {
- return editor.transact((tr) => {
- const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr);
-
- return tr.doc.resolve(blockContainer.beforePos).depth > 1;
- });
+ return editor.canExec((state) => unnestCommand(editor)(state.tr));
}
diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.test.ts
index 5a968c49bf..4951b09fb4 100644
--- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vite-plus/test";
import { setupTestEnv } from "../../setupTestEnv.js";
+import { updateBlock } from "../updateBlock/updateBlock.js";
import { removeAndInsertBlocks } from "./replaceBlocks.js";
import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import { PartialBlock } from "../../../../blocks/defaultBlocks.js";
@@ -233,3 +234,73 @@ describe("Test replaceBlocks", () => {
expect(getEditor().document).toMatchSnapshot();
});
});
+
+// `removeAndInsertBlocks` walks the document while mutating it, so the
+// positions it reads go stale as it goes. It corrects for that with
+// `tr.mapping.slice(stepsBefore)`, where `stepsBefore` is the step count on
+// entry. The slice is what makes the function safe to call on a transaction
+// that already carries steps: an unsliced `tr.mapping` would re-apply the
+// caller's earlier steps to positions that already account for them, and the
+// resulting delete ranges would land on the wrong nodes.
+describe("Test replaceBlocks on a transaction that already has steps", () => {
+ it("Removes the right blocks across two calls in one transaction", () => {
+ const editor = getEditor();
+ const before = editor.document;
+
+ editor.transact((tr) => {
+ removeAndInsertBlocks(tr, ["paragraph-0"], []);
+ removeAndInsertBlocks(tr, ["paragraph-2"], []);
+ });
+
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ expect(editor.document).toEqual(
+ before.filter(
+ (block) => block.id !== "paragraph-0" && block.id !== "paragraph-2",
+ ),
+ );
+ });
+
+ it("Removes the right block after the caller has already updated one", () => {
+ const editor = getEditor();
+ const before = editor.document;
+
+ editor.transact((tr) => {
+ // Changes the size of a block that sits before the one removed below,
+ // so the removal's positions are only correct if the earlier step is
+ // accounted for exactly once.
+ updateBlock(tr, "paragraph-0", {
+ type: "heading",
+ content: "Updated heading",
+ });
+ const inserted: PartialBlock[] = [
+ { id: "inserted-paragraph", type: "paragraph", content: "Inserted" },
+ ];
+ removeAndInsertBlocks(tr, ["paragraph-2"], inserted);
+ });
+
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+
+ const updated = editor.getBlock("paragraph-0")!;
+ expect(updated.type).toBe("heading");
+ expect(updated.content).toEqual([
+ { type: "text", text: "Updated heading", styles: {} },
+ ]);
+
+ expect(editor.document.map((block) => block.id)).toEqual(
+ before.map((block) =>
+ block.id === "paragraph-2" ? "inserted-paragraph" : block.id,
+ ),
+ );
+ // Every block the two operations didn't target is left exactly as it was.
+ expect(
+ editor.document.filter(
+ (block) =>
+ block.id !== "paragraph-0" && block.id !== "inserted-paragraph",
+ ),
+ ).toEqual(
+ before.filter(
+ (block) => block.id !== "paragraph-0" && block.id !== "paragraph-2",
+ ),
+ );
+ });
+});
diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts
index d9e1e72981..3b8a364c2c 100644
--- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts
@@ -11,7 +11,8 @@ import type {
import { blockToNode } from "../../../nodeConversions/blockToNode.js";
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
import { getPmSchema } from "../../../pmUtil.js";
-import { fixColumnList } from "./util/fixColumnList.js";
+import { fixContainersById } from "../../containers/fixContainer.js";
+import { getAncestorContainers } from "../../containers/containerNav.js";
export function removeAndInsertBlocks<
BSchema extends BlockSchema,
@@ -22,7 +23,7 @@ export function removeAndInsertBlocks<
blocksToRemove: BlockIdentifier[],
blocksToInsert: PartialBlock[],
options: {
- fixColumns?: boolean;
+ fixContainers?: boolean;
} = {},
): {
insertedBlocks: Block[];
@@ -43,13 +44,21 @@ export function removeAndInsertBlocks<
),
);
const removedBlocks: Block[] = [];
- const columnListPositions = new Set();
+ // Ancestor containers of removed blocks, to repair afterwards. Tracked by
+ // node id (not position) since the removals and earlier repairs shift
+ // positions; recorded with their depth so repairs run deepest-first.
+ const containersToFix: { id: string; depth: number }[] = [];
const idOfFirstBlock =
typeof blocksToRemove[0] === "string"
? blocksToRemove[0]
: blocksToRemove[0].id;
- let removedSize = 0;
+
+ // The walk below reads the document as it is now, but mutates it as it
+ // goes, so its positions go stale. `tr.mapping` already tracks exactly
+ // that; sliced from here so it ignores steps the caller added earlier.
+ const stepsBefore = tr.steps.length;
+ const mapPos = (pos: number) => tr.mapping.slice(stepsBefore).map(pos);
tr.doc.descendants((node, pos) => {
// Skips traversing nodes after all target blocks have been removed.
@@ -73,39 +82,35 @@ export function removeAndInsertBlocks<
idsOfBlocksToRemove.delete(nodeId);
if (blocksToInsert.length > 0 && nodeId === idOfFirstBlock) {
- const oldDocSize = tr.doc.nodeSize;
- tr.insert(pos, nodesToInsert);
- const newDocSize = tr.doc.nodeSize;
-
- removedSize += oldDocSize - newDocSize;
+ tr.insert(mapPos(pos), nodesToInsert);
}
- const oldDocSize = tr.doc.nodeSize;
+ const $pos = tr.doc.resolve(mapPos(pos));
- const $pos = tr.doc.resolve(pos - removedSize);
-
- if ($pos.node().type.name === "column") {
- columnListPositions.add($pos.before(-1));
- } else if ($pos.node().type.name === "columnList") {
- columnListPositions.add($pos.before());
+ for (const container of getAncestorContainers($pos.doc, $pos.pos)) {
+ if (!containersToFix.some((c) => c.id === container.id)) {
+ containersToFix.push(container);
+ }
}
+ // When the block is the only child of a nested `blockGroup`, delete the
+ // group with it (`blockGroup` acting as a `min: 1, whenEmptied: "unwrap"`
+ // container). This can't route through `fixContainer`: repair runs after
+ // the delete, and by then ProseMirror's replace-fitting has padded the
+ // `blockGroupChild+` group with a fresh empty `blockContainer`
+ // indistinguishable from an intentional one. Only here, before the
+ // delete, is "this was the group's last child" still knowable.
+ const parent = $pos.node();
if (
- $pos.node().type.name === "blockGroup" &&
+ parent.type.name === "blockGroup" &&
$pos.node($pos.depth - 1).type.name !== "doc" &&
- $pos.node().childCount === 1
+ parent.childCount === 1
) {
- // Checks if the block is the only child of a parent `blockGroup` node.
- // In this case, we need to delete the parent `blockGroup` node instead
- // of just the `blockContainer`.
tr.delete($pos.before(), $pos.after());
} else {
- tr.delete(pos - removedSize, pos - removedSize + node.nodeSize);
+ tr.delete($pos.pos, $pos.pos + node.nodeSize);
}
- const newDocSize = tr.doc.nodeSize;
- removedSize += oldDocSize - newDocSize;
-
return false;
});
@@ -119,11 +124,12 @@ export function removeAndInsertBlocks<
);
}
- // Collapses empty columns/columnLists. Callers where the removal isn't a
- // deletion can opt out - e.g. `moveBlocks` re-inserts the blocks elsewhere
- // and deliberately leaves emptied columns as-is.
- if (options.fixColumns !== false) {
- columnListPositions.forEach((pos) => fixColumnList(tr, pos));
+ // Repairs the containers the removed blocks lived in (e.g. collapses
+ // emptied columns/columnLists), deepest-first. Callers where the removal
+ // isn't a deletion can opt out, e.g. `moveBlocks` re-inserts the blocks
+ // elsewhere and deliberately leaves emptied containers as-is.
+ if (options.fixContainers !== false) {
+ fixContainersById(tr, containersToFix);
}
// Converts the nodes created from `blocksToInsert` into full `Block`s.
diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts
deleted file mode 100644
index 3097851f47..0000000000
--- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts
+++ /dev/null
@@ -1,173 +0,0 @@
-import { Slice, type Node } from "prosemirror-model";
-import { type Transaction } from "prosemirror-state";
-import { ReplaceAroundStep } from "prosemirror-transform";
-
-/**
- * Checks if a `column` node is empty, i.e. if it has only a single empty
- * paragraph.
- * @param column The column to check.
- * @returns Whether the column is empty.
- */
-export function isEmptyColumn(column: Node) {
- if (!column || column.type.name !== "column") {
- throw new Error("Invalid columnPos: does not point to column node.");
- }
-
- const blockContainer = column.firstChild;
- if (!blockContainer) {
- throw new Error("Invalid column: does not have child node.");
- }
-
- const blockContent = blockContainer.firstChild;
- if (!blockContent) {
- throw new Error("Invalid blockContainer: does not have child node.");
- }
-
- return (
- column.childCount === 1 &&
- blockContainer.childCount === 1 &&
- blockContent.type.name === "paragraph" &&
- blockContent.content.content.length === 0
- );
-}
-
-/**
- * Removes all empty `column` nodes in a `columnList`. A `column` node is empty
- * if it has only a single empty block. If, however, removing the `column`s
- * leaves the `columnList` that has fewer than two, ProseMirror will re-add
- * empty columns.
- * @param tr The `Transaction` to add the changes to.
- * @param columnListPos The position just before the `columnList` node.
- */
-export function removeEmptyColumns(tr: Transaction, columnListPos: number) {
- const $columnListPos = tr.doc.resolve(columnListPos);
- const columnList = $columnListPos.nodeAfter;
- if (!columnList || columnList.type.name !== "columnList") {
- throw new Error(
- "Invalid columnListPos: does not point to columnList node.",
- );
- }
-
- for (
- let columnIndex = columnList.childCount - 1;
- columnIndex >= 0;
- columnIndex--
- ) {
- const columnPos = tr.doc
- .resolve($columnListPos.pos + 1)
- .posAtIndex(columnIndex);
- const $columnPos = tr.doc.resolve(columnPos);
- const column = $columnPos.nodeAfter;
- if (!column || column.type.name !== "column") {
- throw new Error("Invalid columnPos: does not point to column node.");
- }
-
- if (isEmptyColumn(column)) {
- tr.delete(columnPos, columnPos + column.nodeSize);
- }
- }
-}
-
-/**
- * Fixes potential issues in a `columnList` node after a
- * `blockContainer`/`column` node is (re)moved from it:
- *
- * - Removes all empty `column` nodes. A `column` node is empty if it has only
- * a single empty block.
- * - If all but one `column` nodes are empty, replaces the `columnList` with
- * the content of the non-empty `column`.
- * - If all `column` nodes are empty, removes the `columnList` entirely.
- * @param tr The `Transaction` to add the changes to.
- * @param columnListPos
- * @returns The position just before the `columnList` node.
- */
-export function fixColumnList(tr: Transaction, columnListPos: number) {
- removeEmptyColumns(tr, columnListPos);
-
- const $columnListPos = tr.doc.resolve(columnListPos);
- const columnList = $columnListPos.nodeAfter;
- if (!columnList || columnList.type.name !== "columnList") {
- throw new Error(
- "Invalid columnListPos: does not point to columnList node.",
- );
- }
-
- if (columnList.childCount > 2) {
- // Do nothing if the `columnList` has more than two non-empty `column`s. In
- // the case that the `columnList` has exactly two columns, we may need to
- // still remove it, as it's possible that one or both columns are empty.
- // This is because after `removeEmptyColumns` is called, if the
- // `columnList` has fewer than two `column`s, ProseMirror will re-add empty
- // `column`s until there are two total, in order to fit the schema.
- return;
- }
-
- if (columnList.childCount < 2) {
- // Throw an error if the `columnList` has fewer than two columns. After
- // `removeEmptyColumns` is called, if the `columnList` has fewer than two
- // `column`s, ProseMirror will re-add empty `column`s until there are two
- // total, in order to fit the schema. So if there are fewer than two here,
- // either the schema, or ProseMirror's internals, must have changed.
- throw new Error("Invalid columnList: contains fewer than two children.");
- }
-
- const firstColumnBeforePos = columnListPos + 1;
- const $firstColumnBeforePos = tr.doc.resolve(firstColumnBeforePos);
- const firstColumn = $firstColumnBeforePos.nodeAfter;
-
- const lastColumnAfterPos = columnListPos + columnList.nodeSize - 1;
- const $lastColumnAfterPos = tr.doc.resolve(lastColumnAfterPos);
- const lastColumn = $lastColumnAfterPos.nodeBefore;
-
- if (!firstColumn || !lastColumn) {
- throw new Error("Invalid columnList: does not contain children.");
- }
-
- const firstColumnEmpty = isEmptyColumn(firstColumn);
- const lastColumnEmpty = isEmptyColumn(lastColumn);
-
- if (firstColumnEmpty && lastColumnEmpty) {
- // Removes `columnList`
- tr.delete(columnListPos, columnListPos + columnList.nodeSize);
-
- return;
- }
-
- if (firstColumnEmpty) {
- tr.step(
- new ReplaceAroundStep(
- // Replaces `columnList`.
- columnListPos,
- columnListPos + columnList.nodeSize,
- // Replaces with content of last `column`.
- lastColumnAfterPos - lastColumn.nodeSize + 1,
- lastColumnAfterPos - 1,
- // Doesn't append anything.
- Slice.empty,
- 0,
- false,
- ),
- );
-
- return;
- }
-
- if (lastColumnEmpty) {
- tr.step(
- new ReplaceAroundStep(
- // Replaces `columnList`.
- columnListPos,
- columnListPos + columnList.nodeSize,
- // Replaces with content of first `column`.
- firstColumnBeforePos + 1,
- firstColumnBeforePos + firstColumn.nodeSize - 1,
- // Doesn't append anything.
- Slice.empty,
- 0,
- false,
- ),
- );
-
- return;
- }
-}
diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts
index ab02a865f0..eb57e39c85 100644
--- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts
@@ -3,11 +3,12 @@ import { TextSelection } from "prosemirror-state";
import { describe, expect, it } from "vite-plus/test";
import {
- getBlockInfo,
+ getBlockInfoFromNode,
getBlockInfoFromSelection,
getNodeId,
} from "../../../getBlockInfoFromPos.js";
import { getNodeById } from "../../../nodeUtil.js";
+import { containerSchema } from "../../containers/containers.fixture.js";
import { setupTestEnv } from "../../setupTestEnv.js";
import { splitBlockCommand } from "./splitBlock.js";
@@ -33,15 +34,15 @@ function setSelectionWithOffset(
throw new Error(`Block with ID ${targetBlockId} not found`);
}
- const info = getBlockInfo(posInfo);
+ const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode);
- if (!info.isBlockContainer) {
+ if (!info.hasContent) {
throw new Error("Target block is not a block container");
}
getEditor().transact((tr) =>
tr.setSelection(
- TextSelection.create(doc, info.blockContent.beforePos + offset + 1),
+ TextSelection.create(doc, info.content.beforePos + offset + 1),
),
);
}
@@ -139,7 +140,7 @@ describe("Test splitBlocks", () => {
splitBlock(getEditor().transact((tr) => tr.selection.anchor));
const blockId = getEditor().transact((tr) =>
- getNodeId(getBlockInfoFromSelection(tr).bnBlock.node, tr.doc),
+ getNodeId(getBlockInfoFromSelection(tr).block.node, tr.doc),
);
const anchorIsAtStartOfNewBlock =
@@ -149,3 +150,155 @@ describe("Test splitBlocks", () => {
expect(anchorIsAtStartOfNewBlock).toBeTruthy();
});
});
+
+// `splitBlockTr` splits two levels deep (`blockContent` and its
+// `blockContainer`), which assumes the block's parent is a children holder that
+// accepts another `blockContainer`. A container's children holder is a
+// different node type than `blockGroup`, so these pin that the split lands
+// inside the container rather than tearing it open.
+describe("Test splitBlocks inside containers", () => {
+ const getContainerEditor = setupTestEnv({
+ schema: containerSchema,
+ document: [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ id: "callout-0",
+ type: "callout",
+ children: [
+ {
+ id: "callout-child-0",
+ type: "paragraph",
+ content: "Callout child",
+ },
+ {
+ id: "callout-child-1",
+ type: "heading",
+ content: "Callout heading",
+ children: [
+ {
+ id: "nested-child",
+ type: "paragraph",
+ content: "Nested child",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: "grid-0",
+ type: "grid",
+ children: [
+ {
+ id: "cell-0",
+ type: "gridCell",
+ children: [
+ { id: "cell-0-p", type: "paragraph", content: "Cell zero" },
+ ],
+ },
+ {
+ id: "cell-1",
+ type: "gridCell",
+ children: [
+ { id: "cell-1-p", type: "paragraph", content: "Cell one" },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+
+ function splitContainerBlock(blockId: string, offset: number) {
+ const editor = getContainerEditor();
+
+ const posInBlock = editor.transact((tr) => {
+ const posInfo = getNodeById(blockId, tr.doc);
+ if (!posInfo) {
+ throw new Error(`Block with ID ${blockId} not found`);
+ }
+
+ const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode);
+
+ // A container has no content to offset into, so we aim at the node
+ // itself, which is where a `NodeSelection` on it would put the anchor.
+ return info.hasContent
+ ? info.content.beforePos + offset + 1
+ : info.block.beforePos;
+ });
+
+ return editor._tiptapEditor.commands.command(
+ splitBlockCommand(posInBlock, true),
+ );
+ }
+
+ function textOf(block: { content?: any }) {
+ return (block.content as { text: string }[]).map((c) => c.text).join("");
+ }
+
+ it("Splits a block inside a container in place", () => {
+ expect(splitContainerBlock("callout-child-0", 7)).toBe(true);
+
+ const document = getContainerEditor().document;
+
+ expect(document.map((block) => block.id)).toEqual([
+ "before",
+ "callout-0",
+ "grid-0",
+ ]);
+
+ const callout = document[1];
+ expect(callout.type).toBe("callout");
+ expect(callout.children.map(textOf)).toEqual([
+ "Callout",
+ " child",
+ "Callout heading",
+ ]);
+
+ expect(() =>
+ getContainerEditor().prosemirrorState.doc.check(),
+ ).not.toThrow();
+ });
+
+ it("Moves the block's children onto the second half of the split", () => {
+ expect(splitContainerBlock("callout-child-1", 7)).toBe(true);
+
+ const callout = getContainerEditor().document[1];
+
+ expect(callout.children.map(textOf)).toEqual([
+ "Callout child",
+ "Callout",
+ " heading",
+ ]);
+ // The children follow the trailing half, as they do at the top level.
+ expect(callout.children[1].children).toEqual([]);
+ expect(callout.children[2].children.map((child) => child.id)).toEqual([
+ "nested-child",
+ ]);
+
+ expect(() =>
+ getContainerEditor().prosemirrorState.doc.check(),
+ ).not.toThrow();
+ });
+
+ it("Splits a block inside a nested container", () => {
+ expect(splitContainerBlock("cell-0-p", 4)).toBe(true);
+
+ const grid = getContainerEditor().document[2];
+
+ expect(grid.type).toBe("grid");
+ expect(grid.children.map((cell) => cell.id)).toEqual(["cell-0", "cell-1"]);
+ expect(grid.children[0].children.map(textOf)).toEqual(["Cell", " zero"]);
+ expect(grid.children[1].children.map(textOf)).toEqual(["Cell one"]);
+
+ expect(() =>
+ getContainerEditor().prosemirrorState.doc.check(),
+ ).not.toThrow();
+ });
+
+ it("Does not split a container block itself", () => {
+ const before = getContainerEditor().document;
+
+ expect(splitContainerBlock("callout-0", 0)).toBe(false);
+
+ expect(getContainerEditor().document).toEqual(before);
+ });
+});
diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
index 1e73471d23..d5229da6bf 100644
--- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
@@ -1,7 +1,7 @@
import { EditorState, Transaction } from "prosemirror-state";
import {
- getBlockInfo,
+ getBlockInfoFromNode,
getNearestBlockPos,
} from "../../../getBlockInfoFromPos.js";
import { getPmSchema } from "../../../pmUtil.js";
@@ -34,21 +34,24 @@ export const splitBlockTr = (
): boolean => {
const nearestBlockContainerPos = getNearestBlockPos(tr.doc, posInBlock);
- const info = getBlockInfo(nearestBlockContainerPos);
+ const info = getBlockInfoFromNode(
+ nearestBlockContainerPos.node,
+ nearestBlockContainerPos.posBeforeNode,
+ );
- if (!info.isBlockContainer) {
+ if (!info.hasContent) {
return false;
}
const schema = getPmSchema(tr);
const types = [
{
- type: info.bnBlock.node.type, // always keep blockcontainer type
- attrs: keepProps ? { ...info.bnBlock.node.attrs, id: undefined } : {},
+ type: info.block.node.type, // always keep blockcontainer type
+ attrs: keepProps ? { ...info.block.node.attrs, id: undefined } : {},
},
{
- type: keepType ? info.blockContent.node.type : schema.nodes["paragraph"],
- attrs: keepProps ? { ...info.blockContent.node.attrs } : {},
+ type: keepType ? info.content.node.type : schema.nodes["paragraph"],
+ attrs: keepProps ? { ...info.content.node.attrs } : {},
},
];
diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts
index e44e4a6380..2d6dc782fa 100644
--- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts
@@ -1,8 +1,9 @@
import { describe, expect, it } from "vite-plus/test";
import type { PartialBlock } from "../../../../blocks/defaultBlocks.js";
-import { getBlockInfo } from "../../../getBlockInfoFromPos.js";
+import { getBlockInfoFromNode } from "../../../getBlockInfoFromPos.js";
import { getNodeById } from "../../../nodeUtil.js";
+import { containerSchema } from "../../containers/containers.fixture.js";
import { setupTestEnv } from "../../setupTestEnv.js";
import { updateBlock } from "./updateBlock.js";
@@ -177,11 +178,13 @@ describe("Test updateBlock", () => {
});
it("Update partial (offset start)", () => {
- const info = getBlockInfo(
- getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!,
- );
+ const posInfo = getNodeById(
+ "heading-with-everything",
+ getEditor().prosemirrorState.doc,
+ )!;
+ const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode);
- if (!info.isBlockContainer) {
+ if (!info.hasContent) {
throw new Error("heading-with-everything is not a block container");
}
@@ -198,7 +201,7 @@ describe("Test updateBlock", () => {
},
],
},
- info.blockContent.beforePos + 9,
+ info.content.beforePos + 9,
),
);
@@ -206,11 +209,13 @@ describe("Test updateBlock", () => {
});
it("Update partial (offset start + end)", () => {
- const info = getBlockInfo(
- getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!,
- );
+ const posInfo = getNodeById(
+ "heading-with-everything",
+ getEditor().prosemirrorState.doc,
+ )!;
+ const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode);
- if (!info.isBlockContainer) {
+ if (!info.hasContent) {
throw new Error("heading-with-everything is not a block container");
}
@@ -227,8 +232,8 @@ describe("Test updateBlock", () => {
},
],
},
- info.blockContent.beforePos + 9,
- info.blockContent.beforePos + 9,
+ info.content.beforePos + 9,
+ info.content.beforePos + 9,
),
);
@@ -236,11 +241,13 @@ describe("Test updateBlock", () => {
});
it("Update partial (props + offset end)", () => {
- const info = getBlockInfo(
- getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!,
- );
+ const posInfo = getNodeById(
+ "heading-with-everything",
+ getEditor().prosemirrorState.doc,
+ )!;
+ const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode);
- if (!info.isBlockContainer) {
+ if (!info.hasContent) {
throw new Error("heading-with-everything is not a block container");
}
@@ -261,7 +268,7 @@ describe("Test updateBlock", () => {
],
},
undefined,
- info.blockContent.beforePos + 8,
+ info.content.beforePos + 8,
);
});
@@ -269,15 +276,14 @@ describe("Test updateBlock", () => {
});
it("Update partial (table cell)", () => {
- const info = getBlockInfo(
- getNodeById("table-0", getEditor().prosemirrorState.doc)!,
- );
+ const posInfo = getNodeById("table-0", getEditor().prosemirrorState.doc)!;
+ const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode);
- if (!info.isBlockContainer) {
+ if (!info.hasContent) {
throw new Error("table-0 is not a block container");
}
- const cell = info.blockContent.node.resolve(2);
+ const cell = info.content.node.resolve(2);
getEditor().transact((tr) =>
updateBlock(
@@ -290,8 +296,8 @@ describe("Test updateBlock", () => {
rows: [{ cells: ["updated cell 1"] }],
},
},
- info.blockContent.beforePos + 2,
- info.blockContent.beforePos + 2 + cell.node().nodeSize,
+ info.content.beforePos + 2,
+ info.content.beforePos + 2 + cell.node().nodeSize,
),
);
@@ -299,15 +305,14 @@ describe("Test updateBlock", () => {
});
it("Update partial (table row)", () => {
- const info = getBlockInfo(
- getNodeById("table-0", getEditor().prosemirrorState.doc)!,
- );
+ const posInfo = getNodeById("table-0", getEditor().prosemirrorState.doc)!;
+ const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode);
- if (!info.isBlockContainer) {
+ if (!info.hasContent) {
throw new Error("table-0 is not a block container");
}
- const cell = info.blockContent.node.resolve(1);
+ const cell = info.content.node.resolve(1);
getEditor().transact((tr) =>
updateBlock(
@@ -324,8 +329,8 @@ describe("Test updateBlock", () => {
],
},
},
- info.blockContent.beforePos + 1,
- info.blockContent.beforePos + 1 + cell.node().nodeSize,
+ info.content.beforePos + 1,
+ info.content.beforePos + 1 + cell.node().nodeSize,
),
);
@@ -934,13 +939,12 @@ describe("Test updateBlock minimal steps", () => {
it("Type change with offset content replace stays minimal and valid", () => {
const editor = getEditor();
- const info = getBlockInfo(
- getNodeById(
- "paragraph-with-styled-content",
- editor.prosemirrorState.doc,
- )!,
- );
- if (!info.isBlockContainer) {
+ const posInfo = getNodeById(
+ "paragraph-with-styled-content",
+ editor.prosemirrorState.doc,
+ )!;
+ const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode);
+ if (!info.hasContent) {
throw new Error("paragraph-with-styled-content is not a block container");
}
@@ -959,8 +963,8 @@ describe("Test updateBlock minimal steps", () => {
props: { level: 3 },
content: [{ type: "text", text: " with NEW ", styles: {} }],
},
- info.blockContent.beforePos + 1 + "Paragraph".length,
- info.blockContent.beforePos + 1 + "Paragraph with styled ".length,
+ info.content.beforePos + 1 + "Paragraph".length,
+ info.content.beforePos + 1 + "Paragraph with styled ".length,
);
steps = tr.steps.map((s) => s.toJSON());
});
@@ -976,3 +980,194 @@ describe("Test updateBlock minimal steps", () => {
expect(() => editor._tiptapEditor.state.doc.check()).not.toThrow();
});
});
+
+// Changing a block's type across the content/container divide can't happen in
+// place, so `updateBlock` rebuilds the node and has to decide what to do with
+// the content the old shape held and the new one can't. These tests pin that
+// decision. Assertions are explicit rather than snapshotted because the point
+// is *where* the carried content ends up.
+describe("Test updateBlock content carry-over", () => {
+ const getContainerEditor = setupTestEnv({
+ schema: containerSchema,
+ document: [
+ {
+ id: "paragraph-with-text",
+ type: "paragraph",
+ content: "Paragraph with text",
+ },
+ {
+ id: "empty-paragraph",
+ type: "paragraph",
+ },
+ {
+ id: "paragraph-with-text-and-children",
+ type: "paragraph",
+ content: "Parent text",
+ children: [
+ {
+ id: "existing-child",
+ type: "paragraph",
+ content: "Existing child",
+ },
+ ],
+ },
+ {
+ id: "table-0",
+ type: "table",
+ content: {
+ type: "tableContent",
+ rows: [{ cells: ["Cell 1", "Cell 2"] }],
+ },
+ },
+ {
+ id: "callout-0",
+ type: "callout",
+ children: [
+ {
+ id: "callout-child",
+ type: "paragraph",
+ content: "Callout child",
+ },
+ ],
+ },
+ ],
+ });
+
+ // A block that changes shape is rebuilt rather than updated in place, and the
+ // rebuilt node is minted a fresh ID. That is long-standing behaviour, not
+ // something the container work introduced, but converting a paragraph into a
+ // container is a far more ordinary action than the paragraph/column
+ // conversions that used to be the only way to reach this path. These tests
+ // therefore address blocks by position, and the first one pins the ID loss so
+ // that fixing it shows up as a deliberate change.
+ it("Moves inline content into a child paragraph when becoming a container", () => {
+ const editor = getContainerEditor();
+ editor.transact((tr) =>
+ updateBlock(tr, "paragraph-with-text", { type: "callout" }),
+ );
+
+ const block = editor.document[0] as any;
+ expect(block.type).toBe("callout");
+ expect(block.id).not.toBe("paragraph-with-text");
+ expect(block.children).toHaveLength(1);
+ expect(block.children[0].type).toBe("paragraph");
+ expect(block.children[0].content).toEqual([
+ { type: "text", text: "Paragraph with text", styles: {} },
+ ]);
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("Seeds a container's default children when there is no content to carry", () => {
+ const editor = getContainerEditor();
+ editor.transact((tr) =>
+ updateBlock(tr, "empty-paragraph", { type: "seededPair" }),
+ );
+
+ // An empty paragraph carries nothing, so the rebuilt node must be passed no
+ // `children` at all: `blockToNode` seeds from the spec's `default` only
+ // when `children` is absent, and pads with empty blocks when it is an
+ // empty array. `seededPair` is used here rather than `callout` because its
+ // `default` and its padding differ — for `callout` both are one empty
+ // paragraph, so the distinction is invisible.
+ const block = editor.document[1] as any;
+ expect(block.type).toBe("seededPair");
+ expect(block.children.map((child: any) => child.content[0]?.text)).toEqual([
+ "Seed A",
+ "Seed B",
+ ]);
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("Puts carried content before existing children", () => {
+ const editor = getContainerEditor();
+ editor.transact((tr) =>
+ updateBlock(tr, "paragraph-with-text-and-children", { type: "callout" }),
+ );
+
+ // The paragraph holding the carried text takes the place the text used to
+ // occupy, i.e. above the children that were already nested under it.
+ const block = editor.document[2] as any;
+ expect(block.type).toBe("callout");
+ expect(block.children.map((child: any) => child.content[0].text)).toEqual([
+ "Parent text",
+ "Existing child",
+ ]);
+ expect(block.children[1].id).toBe("existing-child");
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("Drops table content when becoming a container", () => {
+ const editor = getContainerEditor();
+ // Table content isn't an inline array, so there is no sensible paragraph to
+ // wrap it in. It's dropped, and the container seeds as if the block had
+ // been empty.
+ expect(() =>
+ editor.transact((tr) => updateBlock(tr, "table-0", { type: "callout" })),
+ ).not.toThrow();
+
+ const block = editor.document[3] as any;
+ expect(block.type).toBe("callout");
+ expect(block.content).toBeUndefined();
+ expect(block.children).toHaveLength(1);
+ expect(block.children[0].type).toBe("paragraph");
+ expect(block.children[0].content).toEqual([]);
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("Drops carried content for a container that holds only containers", () => {
+ const editor = getContainerEditor();
+ // `grid` holds `gridCell`s, so there is no slot for the paragraph the
+ // content would be carried over in. Handing it one used to build a node
+ // that failed `check()`, aborting the conversion; instead the content is
+ // dropped and the grid seeds as if the block had been empty. Same shape
+ // as a column list, down to `whenEmptied: "unwrap"` and `min: 2`.
+ expect(() =>
+ editor.transact((tr) =>
+ updateBlock(tr, "paragraph-with-text", { type: "grid" }),
+ ),
+ ).not.toThrow();
+
+ const block = editor.document[0] as any;
+ expect(block.type).toBe("grid");
+ expect(block.content).toBeUndefined();
+ expect(block.children.map((child: any) => child.type)).toEqual([
+ "gridCell",
+ "gridCell",
+ ]);
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("Keeps existing children when a container-only container drops the content", () => {
+ const editor = getContainerEditor();
+ // The carried content has nowhere to go, but the block's own children are
+ // regular blocks the grid's cells can still hold.
+ expect(() =>
+ editor.transact((tr) =>
+ updateBlock(tr, "paragraph-with-text-and-children", {
+ type: "sealedGrid",
+ }),
+ ),
+ ).not.toThrow();
+
+ const block = editor.document[2] as any;
+ expect(block.type).toBe("sealedGrid");
+ expect(block.children.map((child: any) => child.type)).toEqual([
+ "sealedBox",
+ ]);
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("Keeps a container's children and invents no content when becoming a block", () => {
+ const editor = getContainerEditor();
+ editor.transact((tr) =>
+ updateBlock(tr, "callout-0", { type: "paragraph" }),
+ );
+
+ const block = editor.document[4] as any;
+ expect(block.type).toBe("paragraph");
+ expect(block.content).toEqual([]);
+ expect(block.children).toHaveLength(1);
+ expect(block.children[0].id).toBe("callout-child");
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+});
diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
index 6edfc434d5..f73b804f9e 100644
--- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
@@ -2,6 +2,7 @@ import {
Fragment,
type NodeType,
type Node as PMNode,
+ type Schema,
Slice,
} from "prosemirror-model";
import { TextSelection, Transaction } from "prosemirror-state";
@@ -18,16 +19,20 @@ import type { StyleSchema } from "../../../../schema/styles/types.js";
import { UnreachableCaseError } from "../../../../util/typescript.js";
import {
type BlockInfo,
- getBlockInfoFromResolvedPos,
+ getBlockInfoAt,
} from "../../../getBlockInfoFromPos.js";
+import { blockToNode } from "../../../nodeConversions/blockToNode.js";
import {
- blockToNode,
inlineContentToNodes,
tableContentToNodes,
} from "../../../nodeConversions/blockToNode.js";
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
import { getNodeById } from "../../../nodeUtil.js";
-import { getPmSchema } from "../../../pmUtil.js";
+import { getBlockSchema, getPmSchema } from "../../../pmUtil.js";
+import {
+ createBlockGroup,
+ isContainerNode,
+} from "../../../../schema/blocks/children.js";
// for compatibility with tiptap. TODO: remove as we want to remove dependency on tiptap command interface
export const updateBlockCommand = <
@@ -63,7 +68,7 @@ export function updateBlockTr<
replaceFromPos?: number,
replaceToPos?: number,
) {
- const blockInfo = getBlockInfoFromResolvedPos(tr.doc.resolve(posBeforeBlock));
+ const blockInfo = getBlockInfoAt(tr.doc, posBeforeBlock);
let cellAnchor: CellAnchor | null = null;
if (blockInfo.blockNoteType === "table") {
@@ -82,44 +87,33 @@ export function updateBlockTr<
// Adds blockGroup node with child blocks if necessary.
- const oldNodeType = pmSchema.nodes[blockInfo.blockNoteType];
- const newNodeType = pmSchema.nodes[block.type || blockInfo.blockNoteType];
+ const newBlockType = block.type || blockInfo.blockNoteType;
+ const newNodeType = pmSchema.nodes[newBlockType];
const newBnBlockNodeType = newNodeType.isInGroup("bnBlock")
? newNodeType
: pmSchema.nodes["blockContainer"];
- if (blockInfo.isBlockContainer && newNodeType.isInGroup("blockContent")) {
- const replaceFromOffset =
- replaceFromPos !== undefined &&
- replaceFromPos > blockInfo.blockContent.beforePos &&
- replaceFromPos < blockInfo.blockContent.afterPos
- ? replaceFromPos - blockInfo.blockContent.beforePos - 1
- : undefined;
-
- const replaceToOffset =
- replaceToPos !== undefined &&
- replaceToPos > blockInfo.blockContent.beforePos &&
- replaceToPos < blockInfo.blockContent.afterPos
- ? replaceToPos - blockInfo.blockContent.beforePos - 1
- : undefined;
-
- updateChildren(block, tr, blockInfo);
- // The code below determines the new content of the block.
- // or "keep" to keep as-is
- updateBlockContentNode(
- block,
- tr,
- oldNodeType,
- newNodeType,
- blockInfo,
- replaceFromOffset,
- replaceToOffset,
- );
- } else if (!blockInfo.isBlockContainer && newNodeType.isInGroup("bnBlock")) {
- updateChildren(block, tr, blockInfo);
- // old node was a bnBlock type (like column or columnList) and new block as well
- // No op, we just update the bnBlock below (at end of function) and have already updated the children
- } else {
+ const replaceFromOffset =
+ blockInfo.hasContent &&
+ replaceFromPos !== undefined &&
+ replaceFromPos >= blockInfo.contentStart &&
+ replaceFromPos <= blockInfo.contentEnd
+ ? replaceFromPos - blockInfo.contentStart
+ : undefined;
+
+ const replaceToOffset =
+ blockInfo.hasContent &&
+ replaceToPos !== undefined &&
+ replaceToPos >= blockInfo.contentStart &&
+ replaceToPos <= blockInfo.contentEnd
+ ? replaceToPos - blockInfo.contentStart
+ : undefined;
+
+ // `hasContent` is exactly `blockContainer`-ness, and a block type resolves
+ // to either a `blockContent` node (a regular block) or a `bnBlock` one (a
+ // container), so the two together say whether the update keeps the block's
+ // shape. Only a same-shape update can happen in place.
+ if (blockInfo.hasContent !== newNodeType.isInGroup("blockContent")) {
// switching from blockContainer to non-blockContainer or v.v.
// currently breaking for column slash menu items converting empty block
// to column.
@@ -127,29 +121,61 @@ export function updateBlockTr<
// currently, we calculate the new node and replace the entire node with the desired new node.
// for this, we do a nodeToBlock on the existing block to get the children.
// it would be cleaner to use a ReplaceAroundStep, but this is a bit simpler and it's quite an edge case
- const existingBlock = nodeToBlock(blockInfo.bnBlock.node, tr.doc);
+ const existingBlock = nodeToBlock(blockInfo.block.node, tr.doc);
+ const carried = carryOverContent(
+ existingBlock.content,
+ newBlockType,
+ pmSchema,
+ );
+ // If no children are passed in, use the existing block's, but only when
+ // there actually are some. `nodeToBlock` always emits an array, and an
+ // empty one would read as "explicitly childless", suppressing the seeding
+ // a container needs when converting from a childless block.
+ const children = acceptedChildren(
+ [...carried.children, ...existingBlock.children],
+ newNodeType,
+ pmSchema,
+ );
+
const replacementNode = blockToNode(
{
- children: existingBlock.children, // if no children are passed in, use existing children
+ ...(carried.content ? { content: carried.content } : {}),
+ ...(children.length > 0 ? { children } : {}),
...block,
},
pmSchema,
);
replacementNode.check(); // `blockToNode` is lenient; validate before mutating the doc
tr.replaceWith(
- blockInfo.bnBlock.beforePos,
- blockInfo.bnBlock.afterPos,
+ blockInfo.block.beforePos,
+ blockInfo.block.afterPos,
replacementNode,
);
return;
}
+ updateChildren(block, tr, blockInfo);
+
+ if (blockInfo.hasContent) {
+ // The code below determines the new content of the block.
+ // or "keep" to keep as-is
+ updateBlockContentNode(
+ block,
+ tr,
+ pmSchema.nodes[blockInfo.blockNoteType],
+ newNodeType,
+ blockInfo,
+ replaceFromOffset,
+ replaceToOffset,
+ );
+ }
+
// Adds all provided props as attributes to the parent blockContainer node too, and also preserves existing
// attributes. Uses minimal steps so that an unchanged container (e.g. when
// only children or content changed) doesn't emit a step at all.
- setNodeMarkupMinimal(tr, blockInfo.bnBlock.beforePos, newBnBlockNodeType, {
+ setNodeMarkupMinimal(tr, blockInfo.block.beforePos, newBnBlockNodeType, {
...block.props,
});
@@ -158,6 +184,75 @@ export function updateBlockTr<
}
}
+function carryOverContent(
+ existingContent: Block["content"],
+ newBlockType: string,
+ pmSchema: Schema,
+): {
+ content?: PartialBlock["content"];
+ children: PartialBlock[];
+} {
+ const nothing = { children: [] };
+
+ if (!existingContent || !Array.isArray(existingContent)) {
+ return nothing;
+ }
+ if (existingContent.length === 0) {
+ return nothing;
+ }
+
+ const targetConfig = getBlockSchema(pmSchema)[newBlockType];
+ if (!targetConfig) {
+ return nothing;
+ }
+
+ if (targetConfig.content === "inline" || targetConfig.content === "plain") {
+ return { content: existingContent, children: [] };
+ }
+
+ if (targetConfig.children !== undefined) {
+ // Offered as a child rather than as content; whether the container can
+ // actually hold it is decided by `acceptedChildren` in the caller, which
+ // asks the same of the block's pre-existing children.
+ return {
+ children: [{ type: "paragraph", content: existingContent } as any],
+ };
+ }
+
+ return nothing;
+}
+
+/**
+ * The subset of `children` a block of `newNodeType` can hold.
+ *
+ * A container that takes only other containers (a column list takes columns)
+ * has no slot for a paragraph, so a conversion into one has to drop the
+ * children that don't fit rather than build a node that fails `check()` and
+ * aborts the whole update. Regular blocks nest in a `blockGroup`, which takes
+ * anything, so only containers filter.
+ */
+function acceptedChildren(
+ children: PartialBlock[],
+ newNodeType: NodeType,
+ pmSchema: Schema,
+): PartialBlock[] {
+ if (!isContainerNode(newNodeType)) {
+ return children;
+ }
+
+ return children.filter((child) => {
+ // Every regular block is the same ProseMirror node, so a child that isn't
+ // a container type is a `blockContainer`.
+ const childType = child.type ? pmSchema.nodes[child.type] : undefined;
+ const asNodeType =
+ childType && isContainerNode(childType)
+ ? childType
+ : pmSchema.nodes["blockContainer"];
+
+ return !!newNodeType.contentMatch.matchType(asNodeType);
+ });
+}
+
function updateBlockContentNode<
BSchema extends BlockSchema,
I extends InlineContentSchema,
@@ -168,10 +263,10 @@ function updateBlockContentNode<
oldNodeType: NodeType,
newNodeType: NodeType,
blockInfo: {
- childContainer?:
+ children?:
| { node: PMNode; beforePos: number; afterPos: number }
| undefined;
- blockContent: { node: PMNode; beforePos: number; afterPos: number };
+ content: { node: PMNode; beforePos: number; afterPos: number };
},
replaceFromOffset?: number,
replaceToOffset?: number,
@@ -201,8 +296,8 @@ function updateBlockContentNode<
// no custom content has been provided, use existing content IF possible
// Since some block types contain inline content and others don't,
// we either need to call setNodeMarkup to just update type &
- // attributes, or replaceWith to replace the whole blockContent.
- const oldContent = blockInfo.blockContent.node.content;
+ // attributes, or replaceWith to replace the whole content.
+ const oldContent = blockInfo.content.node.content;
if (oldNodeType.spec.content === "") {
// keep old content, because it's empty anyway and should be compatible with
// any newContentType
@@ -217,7 +312,7 @@ function updateBlockContentNode<
// for the new type (e.g. converting styled/complex inline content into a
// plain block that disallows formatting marks and inline nodes). Preserve
// the text, dropping the styling the new type can't represent.
- const text = blockInfo.blockContent.node.textContent;
+ const text = blockInfo.content.node.textContent;
content = text.length > 0 ? [pmSchema.text(text)] : [];
} else {
// the content type changed and is incompatible, replace the previous content
@@ -225,7 +320,7 @@ function updateBlockContentNode<
}
}
- // Now, changes the blockContent node type and adds the provided props
+ // Now, changes the content node type and adds the provided props
// as attributes. Also preserves all existing attributes that are
// compatible with the new type.
//
@@ -233,7 +328,7 @@ function updateBlockContentNode<
// content is being replaced or not.
if (content === "keep") {
// only update the type and attributes, keeping the content as-is
- setNodeMarkupMinimal(tr, blockInfo.blockContent.beforePos, newNodeType, {
+ setNodeMarkupMinimal(tr, blockInfo.content.beforePos, newNodeType, {
...block.props,
});
} else if (replaceFromOffset !== undefined || replaceToOffset !== undefined) {
@@ -241,7 +336,7 @@ function updateBlockContentNode<
// position back.
const contentBeforePos = setNodeMarkupMinimalAndRemap(
tr,
- blockInfo.blockContent.beforePos,
+ blockInfo.content.beforePos,
newNodeType,
{ ...block.props },
);
@@ -250,7 +345,7 @@ function updateBlockContentNode<
const end =
contentBeforePos +
1 +
- (replaceToOffset ?? blockInfo.blockContent.node.content.size);
+ (replaceToOffset ?? blockInfo.content.node.content.size);
// for content like table cells (where the blockcontent has nested PM nodes),
// we need to figure out the correct openStart and openEnd for the slice when replacing
@@ -270,7 +365,7 @@ function updateBlockContentNode<
);
} else if (
newNodeType === oldNodeType ||
- newNodeType.validContent(blockInfo.blockContent.node.content)
+ newNodeType.validContent(blockInfo.content.node.content)
) {
// The new type can hold the existing content, so we can update the markup
// first and then diff the content. This keeps both steps minimal.
@@ -280,7 +375,7 @@ function updateBlockContentNode<
// get its (possibly shifted) position back.
const contentBeforePos = setNodeMarkupMinimalAndRemap(
tr,
- blockInfo.blockContent.beforePos,
+ blockInfo.content.beforePos,
newNodeType,
{ ...block.props },
);
@@ -293,11 +388,11 @@ function updateBlockContentNode<
// between inline content, table content, and no content). We can't update
// the markup in-place, so replace the whole content node atomically.
tr.replaceWith(
- blockInfo.blockContent.beforePos,
- blockInfo.blockContent.afterPos,
+ blockInfo.content.beforePos,
+ blockInfo.content.afterPos,
newNodeType.createChecked(
{
- ...blockInfo.blockContent.node.attrs,
+ ...blockInfo.content.node.attrs,
...block.props,
},
content,
@@ -510,24 +605,23 @@ function updateChildren<
return node;
});
- // Checks if a blockGroup node already exists.
- if (blockInfo.childContainer) {
- // Replaces the child nodes in the existing blockGroup, only touching the
- // range that actually changed (keeping unchanged leading/trailing
- // children untouched).
+ if (blockInfo.children) {
+ // Replaces the child nodes in the existing children holder, only
+ // touching the range that actually changed (keeping unchanged
+ // leading/trailing children untouched).
replaceContentMinimal(
tr,
- blockInfo.childContainer.beforePos,
+ blockInfo.children.beforePos,
Fragment.from(childNodes),
);
- } else {
- if (!blockInfo.isBlockContainer) {
- throw new Error("impossible");
- }
- // Inserts a new blockGroup containing the child nodes created earlier.
+ } else if (blockInfo.hasContent) {
+ // A `blockContainer` with no children yet: its `blockGroup` is lazy
+ // (`blockContent blockGroup?`), so create it around the child nodes and
+ // insert it after the content node. (Containers always have a children
+ // holder, so no holder implies a `blockContainer`.)
tr.insert(
- blockInfo.blockContent.afterPos,
- pmSchema.nodes["blockGroup"].createChecked({}, childNodes),
+ blockInfo.content.afterPos,
+ createBlockGroup(pmSchema, childNodes),
);
}
}
@@ -559,11 +653,14 @@ export function updateBlock<
replaceToPos,
);
- const blockContainerNode = tr.doc
- .resolve(posInfo.posBeforeNode + 1) // TODO: clean?
- .node();
+ // `updateBlockTr` may have replaced the node, so re-resolve it at the same
+ // position (an update never moves the block).
+ const updatedNode = tr.doc.resolve(posInfo.posBeforeNode).nodeAfter;
+ if (!updatedNode) {
+ throw new Error(`Block with ID ${id} not found after update`);
+ }
- return nodeToBlock(blockContainerNode, tr.doc);
+ return nodeToBlock(updatedNode, tr.doc);
}
type CellAnchor = { row: number; col: number; offset: number };
@@ -637,12 +734,12 @@ function restoreCellAnchor(
// 1) Resolve the table node in the current document
let tablePos = -1;
- if (blockInfo.isBlockContainer) {
- // Prefer the blockContent position when available (points directly at the PM table node)
- tablePos = tr.mapping.map(blockInfo.blockContent.beforePos);
+ if (blockInfo.hasContent) {
+ // Prefer the content position when available (points directly at the PM table node)
+ tablePos = tr.mapping.map(blockInfo.content.beforePos);
} else {
- // Fallback: scan within the mapped bnBlock range to find the inner table node
- const start = tr.mapping.map(blockInfo.bnBlock.beforePos);
+ // Fallback: scan within the mapped block range to find the inner table node
+ const start = tr.mapping.map(blockInfo.block.beforePos);
const end = start + (tr.doc.nodeAt(start)?.nodeSize || 0);
tr.doc.nodesBetween(start, end, (node, pos) => {
if (node.type.name === "table") {
diff --git a/packages/core/src/api/blockManipulation/containers/containerNav.ts b/packages/core/src/api/blockManipulation/containers/containerNav.ts
new file mode 100644
index 0000000000..6e5cb87e27
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/containerNav.ts
@@ -0,0 +1,149 @@
+import type { Node, NodeType } from "prosemirror-model";
+
+import { isContainerNode, isSealed } from "../../../schema/blocks/children.js";
+import {
+ type BlockInfo,
+ getBlockInfoFromNode,
+} from "../../getBlockInfoFromPos.js";
+
+/**
+ * The outcome of a descent. `blockedBy` says why no position was found: a
+ * sealed container on the path ("seal"), or nothing on that edge accepting
+ * the type ("schema"). Gesture code tells the two apart to select a sealed
+ * container rather than move content into it.
+ */
+export type InsertionPos =
+ | { pos: number; blockedBy?: undefined }
+ | { pos?: undefined; blockedBy: "seal" | "schema" };
+
+/**
+ * Seal handling for the navigation helpers below. They respect seals, so
+ * content never implicitly crosses a sealed boundary. The block manipulation
+ * API opts out with `allowCrossingSeals`, since an explicit placement is an
+ * intentional crossing.
+ */
+type SealOpts = { allowCrossingSeals?: boolean };
+
+/**
+ * Walks one edge of a block's children, descending through nested containers,
+ * to the deepest position where `nodeType` fits. `edge` picks the trailing
+ * edge (where a new last child goes) or the leading edge.
+ */
+export function descendToInsertionPos(
+ info: BlockInfo,
+ nodeType: NodeType,
+ edge: "first" | "last",
+ opts?: SealOpts,
+): InsertionPos {
+ const children = info.children;
+ if (!children) {
+ return { blockedBy: "schema" };
+ }
+ if (!opts?.allowCrossingSeals && isSealed(children.node)) {
+ return { blockedBy: "seal" };
+ }
+
+ const last = edge === "last";
+ const index = last ? children.node.childCount : 0;
+ // `canReplaceWith` rather than a bare content match: the children already
+ // after the position have to still fit once the new node is spliced in.
+ if (children.node.canReplaceWith(index, index, nodeType)) {
+ return { pos: last ? children.childrenEnd : children.childrenStart };
+ }
+
+ const child = last ? children.node.lastChild : children.node.firstChild;
+ if (!child || !isContainerNode(child.type)) {
+ return { blockedBy: "schema" };
+ }
+ return descendToInsertionPos(
+ getBlockInfoFromNode(
+ child,
+ last ? children.childrenEnd - child.nodeSize : children.childrenStart,
+ ),
+ nodeType,
+ edge,
+ opts,
+ );
+}
+
+/**
+ * Resolves a block to its first leaf block: the block itself when it is not a
+ * container, otherwise the first leaf of its first child. Returns `null` for
+ * an empty container, or when reaching the leaf would cross a sealed
+ * container's boundary.
+ */
+export function getFirstLeafBlock(info: BlockInfo): BlockInfo | null {
+ const children = info.children;
+ if (!children || !isContainerNode(info.block.node.type)) {
+ // Not a container: the block is its own first leaf.
+ return info;
+ }
+ // A sealed container's leaf blocks are not reachable from outside.
+ if (isSealed(info.block.node)) {
+ return null;
+ }
+ const firstChild = children.node.firstChild;
+ if (!firstChild) {
+ return null;
+ }
+ return getFirstLeafBlock(
+ getBlockInfoFromNode(firstChild, children.childrenStart),
+ );
+}
+
+/**
+ * Climbs out of containers until it reaches a position where `nodeType` fits.
+ * `side` picks which edge of each climbed container to land on: `"before"` for
+ * moves that put a block above the containers it leaves (Backspace move-out),
+ * `"after"` for moves that put it below them (Enter-exit).
+ *
+ * Position-based rather than `BlockInfo`-based (unlike the descend/leaf
+ * helpers above) because its input is an arbitrary gap position — a point
+ * between blocks, not a block.
+ */
+export function ascendToInsertablePos(
+ doc: Node,
+ pos: number,
+ nodeType: NodeType,
+ side: "before" | "after" = "before",
+): number | undefined {
+ for (;;) {
+ const $pos = doc.resolve(pos);
+ const parent = $pos.node();
+ if (parent.canReplaceWith($pos.index(), $pos.index(), nodeType)) {
+ return pos;
+ }
+ if ($pos.depth > 0 && isContainerNode(parent.type)) {
+ // Climbing out of a sealed container would move content across its
+ // boundary.
+ if (isSealed(parent)) {
+ return undefined;
+ }
+ pos = side === "before" ? $pos.before() : $pos.after();
+ continue;
+ }
+ return undefined;
+ }
+}
+
+/**
+ * The container ancestors of a position, outermost last, each with its block
+ * id and resolution depth. Used to re-run container repair (`fixContainersById`)
+ * on every container a mutation may have emptied. Position-based for the same
+ * reason as `ascendToInsertablePos`: selections and mapped positions are the
+ * natural inputs.
+ */
+export function getAncestorContainers(
+ doc: Node,
+ pos: number,
+): { id: string; depth: number }[] {
+ const $pos = doc.resolve(pos);
+ const containers: { id: string; depth: number }[] = [];
+ for (let depth = $pos.depth; depth > 0; depth--) {
+ const ancestor = $pos.node(depth);
+ if (isContainerNode(ancestor.type) && ancestor.attrs.id) {
+ containers.push({ id: ancestor.attrs.id, depth });
+ }
+ }
+ return containers;
+}
diff --git a/packages/core/src/api/blockManipulation/containers/containerUI.ts b/packages/core/src/api/blockManipulation/containers/containerUI.ts
new file mode 100644
index 0000000000..ad17e8381f
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/containerUI.ts
@@ -0,0 +1,72 @@
+import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+
+export type ContainerUIInfo = {
+ containerTypes: ReadonlySet;
+ draggableContainerTypes: ReadonlySet;
+ /**
+ * Regular (non-container) block types whose spec sets `meta.draggable:
+ * false`. Container types are tracked separately in
+ * `draggableContainerTypes`, because they're identified in the DOM by
+ * `data-node-type` while regular blocks all share the `blockContainer` node
+ * and are identified by their content's `data-content-type`.
+ */
+ nonDraggableBlockTypes: ReadonlySet;
+ containerSelector: string | null;
+};
+
+function buildSelector(types: ReadonlySet): string | null {
+ if (types.size === 0) {
+ return null;
+ }
+ return [...types].map((type) => `[data-node-type="${type}"]`).join(",");
+}
+
+// The schema never changes over an editor's lifetime, so the info is derived
+// once. It's read on every mousemove, which would otherwise walk every block
+// spec each time.
+const cache = new WeakMap
`,
+ },
+ executeTest: testParseHTML,
+ },
+ {
+ // A container nested inside another, with a non-default prop on each.
+ testCase: {
+ name: "containerNested",
+ content: `
+
+
+
+
+
+
Nested heading
+
+
+
+
+
+
+
+
+
Inner callout child
+
+
+
+
+
+
+
+
`,
+ },
+ executeTest: testParseHTML,
+ },
+ {
+ // A container whose children holder is serialized empty. Parsing goes
+ // through a real document, so the spec's `default` children fill it back
+ // in. This is why `container/emptyChildren` is excluded from the
+ // export/parse equality matrix.
+ testCase: {
+ name: "containerEmptyChildren",
+ content: `
+
+
+
+
`,
+ },
+ executeTest: testParseHTML,
+ },
+ {
+ // The external (`blocksToHTMLLossy`) form, which is what lands on the
+ // clipboard and what another app would paste in. The holder carries no
+ // `data-children-of` marker.
+ testCase: {
+ name: "containerExternalHTML",
+ content: `
+
+
Nested heading
+
+
+
Inner callout child
+
+
+
+
`,
+ },
+ executeTest: testParseHTML,
+ },
];
export const parseTestInstancesMarkdown: TestInstance<
diff --git a/tests/src/unit/core/schema/__snapshots__/blocks.json b/tests/src/unit/core/schema/__snapshots__/blocks.json
index ee48987244..9ae0749202 100644
--- a/tests/src/unit/core/schema/__snapshots__/blocks.json
+++ b/tests/src/unit/core/schema/__snapshots__/blocks.json
@@ -73,6 +73,36 @@
"toExternalHTML": [Function],
},
},
+ "callout": {
+ "config": {
+ "children": {
+ "allow": "any",
+ "default": [
+ {
+ "type": "paragraph",
+ },
+ ],
+ },
+ "content": "none",
+ "propSchema": {
+ "flavor": {
+ "default": "tip",
+ "values": [
+ "tip",
+ "info",
+ "warning",
+ ],
+ },
+ },
+ "type": "callout",
+ },
+ "extensions": undefined,
+ "implementation": {
+ "node": null,
+ "render": [Function],
+ "toExternalHTML": [Function],
+ },
+ },
"checkListItem": {
"config": {
"content": "inline",
diff --git a/tests/src/unit/core/testSchema.ts b/tests/src/unit/core/testSchema.ts
index eca37363fa..c3e03c3227 100644
--- a/tests/src/unit/core/testSchema.ts
+++ b/tests/src/unit/core/testSchema.ts
@@ -99,6 +99,41 @@ const SimpleCustomParagraph = createBlockSpec(
},
);
+// A container block: it holds no inline content of its own, and its `contentDOM`
+// is where its child blocks go. Covers containers in the format-conversion,
+// clipboard and selection matrices, which otherwise never see one.
+const Callout = createBlockSpec(
+ {
+ type: "callout" as const,
+ propSchema: {
+ flavor: {
+ default: "tip" as const,
+ values: ["tip", "info", "warning"] as const,
+ },
+ },
+ content: "none",
+ children: {
+ allow: "any",
+ default: [{ type: "paragraph" }],
+ },
+ },
+ {
+ render: () => {
+ const callout = document.createElement("div");
+ callout.className = "callout";
+
+ const body = document.createElement("div");
+ body.className = "callout-body";
+ callout.appendChild(body);
+
+ return {
+ dom: callout,
+ contentDOM: body,
+ };
+ },
+ },
+);
+
// INLINE CONTENT --------------------------------------------------------------
const Mention = createInlineContentSpec(
@@ -222,6 +257,7 @@ export const testSchema = BlockNoteSchema.create().extend({
customParagraph: CustomParagraph(),
simpleCustomParagraph: SimpleCustomParagraph(),
simpleImage: SimpleImage(),
+ callout: Callout(),
},
inlineContentSpecs: {
mention: Mention,
diff --git a/tests/src/unit/react/useNodeViewBlock.test.tsx b/tests/src/unit/react/useNodeViewBlock.test.tsx
index 71101c7fa7..397c75019b 100644
--- a/tests/src/unit/react/useNodeViewBlock.test.tsx
+++ b/tests/src/unit/react/useNodeViewBlock.test.tsx
@@ -27,8 +27,15 @@ const createReproBlock = createReactBlockSpec(
{ render: (props) => },
);
+// A container block, whose node view's node is itself the bnBlock, resolved
+// by id instead of by position.
+const createBoxBlock = createReactBlockSpec(
+ { type: "box", propSchema: {}, content: "none", children: { allow: "any" } },
+ { render: (props) => },
+);
+
const schema = BlockNoteSchema.create().extend({
- blockSpecs: { repro: createReproBlock() },
+ blockSpecs: { repro: createReproBlock(), box: createBoxBlock() },
});
let editor: BlockNoteEditor;
@@ -43,6 +50,7 @@ beforeEach(() => {
{ type: "paragraph", content: "first" },
{ type: "repro", content: "target block" },
{ type: "paragraph", content: "last" },
+ { type: "box", children: [{ type: "paragraph", content: "inside" }] },
],
}) as BlockNoteEditor;
@@ -78,11 +86,14 @@ function renderHook(
return resolved;
}
-// Only the two fields `useNodeViewBlock` reads. Built structurally so `tests`
-// doesn't need a dependency on `@tiptap/react` just for its prop types.
-function makeProps(getPos: () => number | undefined) {
+// Only the fields `useNodeViewBlock` reads. Built structurally so `tests`
+// doesn't need a dependency on `@tiptap/react` just for its prop types. The
+// `node` defaults to a regular (non-container) block's node shape; container
+// tests pass the real PM node instead.
+function makeProps(getPos: () => number | undefined, node?: unknown) {
return {
getPos,
+ node: node ?? { type: { isInGroup: () => false } },
view: { state: { doc: editor.prosemirrorState.doc } },
} as unknown as Parameters[0];
}
@@ -170,4 +181,34 @@ describe("useNodeViewBlock", () => {
expect(resolved.id).toBe(target.id);
expect(resolved).not.toBe(seed);
});
+
+ it("rejects container blocks loudly instead of resolving the wrong block", () => {
+ const box = editor.document.find((block) => block.type === "box")!;
+ const { node } = getNodeById(box.id, editor.prosemirrorState.doc)!;
+ const props = makeProps(() => undefined, node);
+
+ let captured: unknown;
+
+ function Probe() {
+ useNodeViewBlock(props, box);
+ return null;
+ }
+
+ root = createRoot(div, {
+ // React 19 reports uncaught render errors here instead of rethrowing
+ // out of `flushSync`.
+ onUncaughtError: (error: unknown) => {
+ captured = error;
+ },
+ });
+ try {
+ flushSync(() => {
+ root!.render();
+ });
+ } catch (error) {
+ captured = error;
+ }
+
+ expect(String(captured)).toMatch(/cannot resolve container block "box"/);
+ });
});
diff --git a/tests/vitestSetup.browser.ts b/tests/vitestSetup.browser.ts
index 469a859137..70b3220bab 100644
--- a/tests/vitestSetup.browser.ts
+++ b/tests/vitestSetup.browser.ts
@@ -23,6 +23,20 @@ beforeAll(async () => {
const style = document.createElement("style");
style.textContent = `.bn-container { max-width: 731px; margin: 0 auto; padding-top: 8px; }`;
document.head.appendChild(style);
+
+ // Disable CSS transitions & animations for the whole suite. The editor
+ // animates block geometry (e.g. `.bn-block-outer { transition: margin 0.2s }`
+ // driven by the PreviousBlockType depth-change decorations), so for ~200ms
+ // after a Tab/Shift+Tab the blocks' x-positions are mid-flight. Visual caret
+ // movement (ArrowUp/ArrowDown) then lands at a timing-dependent text offset,
+ // which made document snapshots race the animation clock under CPU
+ // contention. With motion disabled, layout is always in its settled state and
+ // caret geometry is deterministic. (No product code listens for
+ // transitionend/animationend, and no keyframes rely on fill-mode, so
+ // suppressing motion only removes the timing dependency.)
+ const noMotion = document.createElement("style");
+ noMotion.textContent = `*, *::before, *::after { transition: none !important; animation: none !important; }`;
+ document.head.appendChild(noMotion);
});
beforeEach(() => {