diff --git a/.dockerignore b/.dockerignore
index 87239fb0a2..5e9711a8f9 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -5,6 +5,7 @@
# these entries just stop the heavy/irrelevant trees from bloating the context.
**/node_modules
**/dist
+**/.next
**/types
**/.vite
**/.vite-plus
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..d9b56d1216
--- /dev/null
+++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx
@@ -0,0 +1,194 @@
+---
+title: Container Blocks
+description: Learn how to create custom blocks that contain other blocks
+---
+
+# Container Blocks
+
+You can create custom blocks that contain other blocks, such as panels, callouts, and column layouts. Take a look at the demo below, in which we add a custom panel to a BlockNote editor, as well as a custom [Slash Menu item](/docs/react/components/suggestion-menus#changing-slash-menu-items) to insert it. Each panel can contain paragraphs, headings, lists, or any other blocks in the editor.
+
+
` element to display line breaks and spacing:
+
+```tsx
+render: (props) => ,
+```
+
+The child blocks can still contain rich text, images, and other block types.
+
+`renderFrame:` An optional React component for styling the block and its children together, such as giving the callout a shared border or background. It receives `block`, `editor`, and `contentRef`, just like `render`. Attach `contentRef` where the block's content and children should appear. You can use the block's props to customize the frame, add interactive controls, or return `null` to show the block without a frame.
+
+You can also use `renderFrame` without the `children` option to style a block and its indented children together. For a container with `content: "none"`, like the panel above, add the surrounding styling directly in `render`.
+
+Add `callout: createCallout()` to your schema, then use `content` for the title and `children` for the body:
+
+```typescript
+{
+ type: "callout",
+ content: "Before you start",
+ children: [
+ { type: "paragraph", content: "Make sure you have an account." },
+ ],
+}
+```
+
+Pressing Enter at the end of the title starts a paragraph in the body. Moving the callout moves its title and body together.
+
+To add blocks to an existing container, see [Inserting Blocks](/docs/reference/editor/manipulating-content#inserting-blocks).
+
+## Restricting Children
+
+For structured layouts, you can limit a container to specific container types. For example, a column layout should only contain columns, while each column can contain any block.
+
+Use an array of container type names for `children.allow`, and `min` to set the minimum number of children:
+
+```typescript
+// Column layout config:
+children: { allow: ["column"], min: 2 },
+```
+
+On the column itself, set `placeable` to `"namedOnly"` so it can only be used inside a container that explicitly allows it:
+
+```typescript
+// Column config:
+children: { allow: "blocks" },
+placeable: "namedOnly",
+```
+
+`children.allow:` Accepts `"blocks"` or an array of container type names. You cannot list regular block types such as `"paragraph"` individually.
+
+`children.min:` The minimum number of children. Defaults to `1`.
+
+`placeable:` Set to `"namedOnly"` to restrict a container to parents that name it in `children.allow`. Defaults to `"anywhere"`.
+
+These options apply to containers with `content: "none"`. Blocks with `content: "inline"` or `"plain"` use `children: { allow: "blocks" }` and can have no child blocks.
+
+For built-in column blocks, see [Multi-Column Layouts](/docs/foundations/document-structure#column-blocks).
diff --git a/docs/content/docs/features/custom-schemas/custom-blocks.mdx b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
index ff25cf838c..4146907f28 100644
--- a/docs/content/docs/features/custom-schemas/custom-blocks.mdx
+++ b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
@@ -56,6 +56,11 @@ type BlockConfig = {
type: string;
content: "inline" | "plain" | "none";
readonly propSchema: PropSchema;
+ children?: {
+ allow: "blocks" | string[];
+ min?: number;
+ };
+ placeable?: "anywhere" | "namedOnly";
};
```
@@ -72,6 +77,14 @@ type BlockConfig = {
alert, so we set `content` to `"inline"`._
+
+ _Custom blocks can also contain child blocks by declaring the `children`
+ option, with or without editable content of their own. See [Container
+ Blocks](/docs/features/custom-schemas/container-blocks)._
+
+
+`children?:` Defines which child blocks the block can contain. `placeable?:` Controls where a container block can be used. See [Container Blocks](/docs/features/custom-schemas/container-blocks) for the supported configurations.
+
`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 bcc3d57ceb..317879a072 100644
--- a/docs/content/docs/reference/editor/manipulating-content.mdx
+++ b/docs/content/docs/reference/editor/manipulating-content.mdx
@@ -165,14 +165,16 @@ editor.insertBlocks(
"after",
);
-// Insert a paragraph as the last child of an existing block
+// Insert a paragraph as the last child of a container block
editor.insertBlocks(
[{ type: "paragraph", content: "Nested paragraph" }],
- "existing-block-id",
+ "container-block-id",
"last-child",
);
```
+For [container blocks](/docs/features/custom-schemas/container-blocks), `"first-child"` inserts at the beginning of the container and `"last-child"` inserts at the end. Use `"before"` or `"after"` to insert next to the container instead.
+
### Updating Blocks
#### Modifying Existing Blocks
diff --git a/examples/01-basic/01-minimal/vite.config.ts b/examples/01-basic/01-minimal/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/01-minimal/vite.config.ts
+++ b/examples/01-basic/01-minimal/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/02-block-objects/vite.config.ts b/examples/01-basic/02-block-objects/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/02-block-objects/vite.config.ts
+++ b/examples/01-basic/02-block-objects/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/03-multi-column/vite.config.ts b/examples/01-basic/03-multi-column/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/03-multi-column/vite.config.ts
+++ b/examples/01-basic/03-multi-column/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/04-default-blocks/vite.config.ts b/examples/01-basic/04-default-blocks/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/04-default-blocks/vite.config.ts
+++ b/examples/01-basic/04-default-blocks/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/05-removing-default-blocks/vite.config.ts b/examples/01-basic/05-removing-default-blocks/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/05-removing-default-blocks/vite.config.ts
+++ b/examples/01-basic/05-removing-default-blocks/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/06-block-manipulation/vite.config.ts b/examples/01-basic/06-block-manipulation/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/06-block-manipulation/vite.config.ts
+++ b/examples/01-basic/06-block-manipulation/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/07-selection-blocks/vite.config.ts b/examples/01-basic/07-selection-blocks/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/07-selection-blocks/vite.config.ts
+++ b/examples/01-basic/07-selection-blocks/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/08-ariakit/vite.config.ts b/examples/01-basic/08-ariakit/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/08-ariakit/vite.config.ts
+++ b/examples/01-basic/08-ariakit/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/09-shadcn/vite.config.ts b/examples/01-basic/09-shadcn/vite.config.ts
index c990876056..6bfee196fb 100644
--- a/examples/01-basic/09-shadcn/vite.config.ts
+++ b/examples/01-basic/09-shadcn/vite.config.ts
@@ -14,7 +14,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -25,11 +25,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/10-localization/vite.config.ts b/examples/01-basic/10-localization/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/10-localization/vite.config.ts
+++ b/examples/01-basic/10-localization/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/11-custom-placeholder/vite.config.ts b/examples/01-basic/11-custom-placeholder/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/11-custom-placeholder/vite.config.ts
+++ b/examples/01-basic/11-custom-placeholder/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/12-multi-editor/vite.config.ts b/examples/01-basic/12-multi-editor/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/12-multi-editor/vite.config.ts
+++ b/examples/01-basic/12-multi-editor/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/13-custom-paste-handler/vite.config.ts b/examples/01-basic/13-custom-paste-handler/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/13-custom-paste-handler/vite.config.ts
+++ b/examples/01-basic/13-custom-paste-handler/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/14-editor-scrollable/vite.config.ts b/examples/01-basic/14-editor-scrollable/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/14-editor-scrollable/vite.config.ts
+++ b/examples/01-basic/14-editor-scrollable/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/15-shadowdom/vite.config.ts b/examples/01-basic/15-shadowdom/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/15-shadowdom/vite.config.ts
+++ b/examples/01-basic/15-shadowdom/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/16-read-only-editor/vite.config.ts b/examples/01-basic/16-read-only-editor/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/16-read-only-editor/vite.config.ts
+++ b/examples/01-basic/16-read-only-editor/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/17-no-trailing-block/vite.config.ts b/examples/01-basic/17-no-trailing-block/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/17-no-trailing-block/vite.config.ts
+++ b/examples/01-basic/17-no-trailing-block/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/01-basic/testing/vite.config.ts b/examples/01-basic/testing/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/01-basic/testing/vite.config.ts
+++ b/examples/01-basic/testing/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/02-backend/01-file-uploading/vite.config.ts b/examples/02-backend/01-file-uploading/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/02-backend/01-file-uploading/vite.config.ts
+++ b/examples/02-backend/01-file-uploading/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/02-backend/02-saving-loading/vite.config.ts b/examples/02-backend/02-saving-loading/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/02-backend/02-saving-loading/vite.config.ts
+++ b/examples/02-backend/02-saving-loading/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/02-backend/03-s3/vite.config.ts b/examples/02-backend/03-s3/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/02-backend/03-s3/vite.config.ts
+++ b/examples/02-backend/03-s3/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/02-backend/04-rendering-static-documents/vite.config.ts b/examples/02-backend/04-rendering-static-documents/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/02-backend/04-rendering-static-documents/vite.config.ts
+++ b/examples/02-backend/04-rendering-static-documents/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/01-ui-elements-remove/vite.config.ts b/examples/03-ui-components/01-ui-elements-remove/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/01-ui-elements-remove/vite.config.ts
+++ b/examples/03-ui-components/01-ui-elements-remove/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/02-formatting-toolbar-buttons/vite.config.ts b/examples/03-ui-components/02-formatting-toolbar-buttons/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/02-formatting-toolbar-buttons/vite.config.ts
+++ b/examples/03-ui-components/02-formatting-toolbar-buttons/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/03-formatting-toolbar-block-type-items/vite.config.ts b/examples/03-ui-components/03-formatting-toolbar-block-type-items/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/03-formatting-toolbar-block-type-items/vite.config.ts
+++ b/examples/03-ui-components/03-formatting-toolbar-block-type-items/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/04-side-menu-buttons/vite.config.ts b/examples/03-ui-components/04-side-menu-buttons/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/04-side-menu-buttons/vite.config.ts
+++ b/examples/03-ui-components/04-side-menu-buttons/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/05-side-menu-drag-handle-items/vite.config.ts b/examples/03-ui-components/05-side-menu-drag-handle-items/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/05-side-menu-drag-handle-items/vite.config.ts
+++ b/examples/03-ui-components/05-side-menu-drag-handle-items/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/06-suggestion-menus-slash-menu-items/vite.config.ts b/examples/03-ui-components/06-suggestion-menus-slash-menu-items/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/06-suggestion-menus-slash-menu-items/vite.config.ts
+++ b/examples/03-ui-components/06-suggestion-menus-slash-menu-items/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/07-suggestion-menus-slash-menu-component/vite.config.ts b/examples/03-ui-components/07-suggestion-menus-slash-menu-component/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/07-suggestion-menus-slash-menu-component/vite.config.ts
+++ b/examples/03-ui-components/07-suggestion-menus-slash-menu-component/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/vite.config.ts b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/vite.config.ts
+++ b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/vite.config.ts b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/vite.config.ts
+++ b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/vite.config.ts b/examples/03-ui-components/10-suggestion-menus-grid-mentions/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/10-suggestion-menus-grid-mentions/vite.config.ts
+++ b/examples/03-ui-components/10-suggestion-menus-grid-mentions/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/11-uppy-file-panel/vite.config.ts b/examples/03-ui-components/11-uppy-file-panel/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/11-uppy-file-panel/vite.config.ts
+++ b/examples/03-ui-components/11-uppy-file-panel/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/12-static-formatting-toolbar/vite.config.ts b/examples/03-ui-components/12-static-formatting-toolbar/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/12-static-formatting-toolbar/vite.config.ts
+++ b/examples/03-ui-components/12-static-formatting-toolbar/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/13-custom-ui/vite.config.ts b/examples/03-ui-components/13-custom-ui/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/13-custom-ui/vite.config.ts
+++ b/examples/03-ui-components/13-custom-ui/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite.config.ts b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite.config.ts
+++ b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/15-advanced-tables/vite.config.ts b/examples/03-ui-components/15-advanced-tables/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/15-advanced-tables/vite.config.ts
+++ b/examples/03-ui-components/15-advanced-tables/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/16-link-toolbar-buttons/vite.config.ts b/examples/03-ui-components/16-link-toolbar-buttons/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/16-link-toolbar-buttons/vite.config.ts
+++ b/examples/03-ui-components/16-link-toolbar-buttons/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/17-advanced-tables-2/vite.config.ts b/examples/03-ui-components/17-advanced-tables-2/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/17-advanced-tables-2/vite.config.ts
+++ b/examples/03-ui-components/17-advanced-tables-2/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/18-drag-n-drop/vite.config.ts b/examples/03-ui-components/18-drag-n-drop/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/18-drag-n-drop/vite.config.ts
+++ b/examples/03-ui-components/18-drag-n-drop/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/19-suggestion-menus-grouping-ordering/vite.config.ts b/examples/03-ui-components/19-suggestion-menus-grouping-ordering/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/19-suggestion-menus-grouping-ordering/vite.config.ts
+++ b/examples/03-ui-components/19-suggestion-menus-grouping-ordering/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/03-ui-components/20-portal-elements/vite.config.ts b/examples/03-ui-components/20-portal-elements/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/20-portal-elements/vite.config.ts
+++ b/examples/03-ui-components/20-portal-elements/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/04-theming/01-theming-dom-attributes/vite.config.ts b/examples/04-theming/01-theming-dom-attributes/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/04-theming/01-theming-dom-attributes/vite.config.ts
+++ b/examples/04-theming/01-theming-dom-attributes/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/04-theming/02-changing-font/vite.config.ts b/examples/04-theming/02-changing-font/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/04-theming/02-changing-font/vite.config.ts
+++ b/examples/04-theming/02-changing-font/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/04-theming/03-theming-css/vite.config.ts b/examples/04-theming/03-theming-css/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/04-theming/03-theming-css/vite.config.ts
+++ b/examples/04-theming/03-theming-css/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/04-theming/04-theming-css-variables/vite.config.ts b/examples/04-theming/04-theming-css-variables/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/04-theming/04-theming-css-variables/vite.config.ts
+++ b/examples/04-theming/04-theming-css-variables/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/04-theming/05-theming-css-variables-code/vite.config.ts b/examples/04-theming/05-theming-css-variables-code/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/04-theming/05-theming-css-variables-code/vite.config.ts
+++ b/examples/04-theming/05-theming-css-variables-code/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/04-theming/06-code-block/vite.config.ts b/examples/04-theming/06-code-block/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/04-theming/06-code-block/vite.config.ts
+++ b/examples/04-theming/06-code-block/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/04-theming/07-custom-code-block/vite.config.ts b/examples/04-theming/07-custom-code-block/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/04-theming/07-custom-code-block/vite.config.ts
+++ b/examples/04-theming/07-custom-code-block/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/05-interoperability/01-converting-blocks-to-html/vite.config.ts b/examples/05-interoperability/01-converting-blocks-to-html/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/05-interoperability/01-converting-blocks-to-html/vite.config.ts
+++ b/examples/05-interoperability/01-converting-blocks-to-html/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/05-interoperability/02-converting-blocks-from-html/vite.config.ts b/examples/05-interoperability/02-converting-blocks-from-html/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/05-interoperability/02-converting-blocks-from-html/vite.config.ts
+++ b/examples/05-interoperability/02-converting-blocks-from-html/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/05-interoperability/03-converting-blocks-to-md/vite.config.ts b/examples/05-interoperability/03-converting-blocks-to-md/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/05-interoperability/03-converting-blocks-to-md/vite.config.ts
+++ b/examples/05-interoperability/03-converting-blocks-to-md/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/05-interoperability/04-converting-blocks-from-md/vite.config.ts b/examples/05-interoperability/04-converting-blocks-from-md/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/05-interoperability/04-converting-blocks-from-md/vite.config.ts
+++ b/examples/05-interoperability/04-converting-blocks-from-md/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/05-interoperability/05-converting-blocks-to-pdf/vite.config.ts b/examples/05-interoperability/05-converting-blocks-to-pdf/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/05-interoperability/05-converting-blocks-to-pdf/vite.config.ts
+++ b/examples/05-interoperability/05-converting-blocks-to-pdf/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/05-interoperability/06-converting-blocks-to-docx/vite.config.ts b/examples/05-interoperability/06-converting-blocks-to-docx/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/05-interoperability/06-converting-blocks-to-docx/vite.config.ts
+++ b/examples/05-interoperability/06-converting-blocks-to-docx/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/05-interoperability/07-converting-blocks-to-odt/vite.config.ts b/examples/05-interoperability/07-converting-blocks-to-odt/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/05-interoperability/07-converting-blocks-to-odt/vite.config.ts
+++ b/examples/05-interoperability/07-converting-blocks-to-odt/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/vite.config.ts b/examples/05-interoperability/08-converting-blocks-to-react-email/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/05-interoperability/08-converting-blocks-to-react-email/vite.config.ts
+++ b/examples/05-interoperability/08-converting-blocks-to-react-email/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/05-interoperability/09-blocks-to-html-static-render/vite.config.ts b/examples/05-interoperability/09-blocks-to-html-static-render/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/05-interoperability/09-blocks-to-html-static-render/vite.config.ts
+++ b/examples/05-interoperability/09-blocks-to-html-static-render/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/05-interoperability/10-static-html-render/vite.config.ts b/examples/05-interoperability/10-static-html-render/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/05-interoperability/10-static-html-render/vite.config.ts
+++ b/examples/05-interoperability/10-static-html-render/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/05-interoperability/11-converting-blocks-to-pdf-react-pdf-deprecated/vite.config.ts b/examples/05-interoperability/11-converting-blocks-to-pdf-react-pdf-deprecated/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/05-interoperability/11-converting-blocks-to-pdf-react-pdf-deprecated/vite.config.ts
+++ b/examples/05-interoperability/11-converting-blocks-to-pdf-react-pdf-deprecated/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/06-custom-schema/01-alert-block/vite.config.ts b/examples/06-custom-schema/01-alert-block/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/01-alert-block/vite.config.ts
+++ b/examples/06-custom-schema/01-alert-block/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/06-custom-schema/02-suggestion-menus-mentions/vite.config.ts b/examples/06-custom-schema/02-suggestion-menus-mentions/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/02-suggestion-menus-mentions/vite.config.ts
+++ b/examples/06-custom-schema/02-suggestion-menus-mentions/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/06-custom-schema/03-font-style/vite.config.ts b/examples/06-custom-schema/03-font-style/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/03-font-style/vite.config.ts
+++ b/examples/06-custom-schema/03-font-style/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/06-custom-schema/04-pdf-file-block/vite.config.ts b/examples/06-custom-schema/04-pdf-file-block/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/04-pdf-file-block/vite.config.ts
+++ b/examples/06-custom-schema/04-pdf-file-block/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/06-custom-schema/05-alert-block-full-ux/vite.config.ts b/examples/06-custom-schema/05-alert-block-full-ux/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/05-alert-block-full-ux/vite.config.ts
+++ b/examples/06-custom-schema/05-alert-block-full-ux/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/06-custom-schema/06-toggleable-blocks/vite.config.ts b/examples/06-custom-schema/06-toggleable-blocks/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/06-toggleable-blocks/vite.config.ts
+++ b/examples/06-custom-schema/06-toggleable-blocks/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/06-custom-schema/07-configuring-blocks/vite.config.ts b/examples/06-custom-schema/07-configuring-blocks/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/07-configuring-blocks/vite.config.ts
+++ b/examples/06-custom-schema/07-configuring-blocks/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/06-custom-schema/08-non-editable-block/vite.config.ts b/examples/06-custom-schema/08-non-editable-block/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/08-non-editable-block/vite.config.ts
+++ b/examples/06-custom-schema/08-non-editable-block/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
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..999bf62786
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/README.md
@@ -0,0 +1,18 @@
+# Container Block
+
+In this example, we create a custom `Panel` block that holds other blocks as its body, such as a panel containing headings and paragraphs.
+
+The block declares the `children` config on `BlockConfig`. `children: { allow: "blocks" }` makes it a container: its child blocks mount into the rendered content region (attached with `ref={contentRef}`), and live on `block.children` at runtime. A pure container like this draws its box in `render`, which re-renders live when props change.
+
+We also wire up a Slash Menu item to insert the panel.
+
+**Try it out:**
+
+- Press the "/" key inside the panel's body and add a code block, heading, or list.
+- Insert a new panel via the Slash Menu (search "panel").
+
+**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..c93bbe6f87
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/src/App.tsx
@@ -0,0 +1,92 @@
+import { BlockNoteSchema } 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 { RiChatQuoteLine } from "react-icons/ri";
+
+import { createPanel } from "./Panel";
+import "./styles.css";
+
+// Schema with the default blocks plus our custom Panel container block.
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ panel: createPanel(),
+ },
+});
+
+// Slash menu item to insert a Panel. Inserting one with no children fills
+// it with an empty paragraph, as `min` defaults to 1.
+function insertPanel(editor: typeof schema.BlockNoteEditor) {
+ return {
+ title: "Panel",
+ subtext: "Container block that wraps other blocks",
+ onItemClick: () =>
+ insertOrUpdateBlockForSlashMenu(editor, {
+ type: "panel",
+ }),
+ aliases: ["panel", "container", "callout", "alert", "note", "tip", "info"],
+ group: "Basic blocks",
+ icon: ,
+ };
+}
+
+export default function App() {
+ const editor = useCreateBlockNote({
+ schema,
+ initialContent: [
+ {
+ type: "paragraph",
+ content: "Welcome! This demo shows the new container block kind.",
+ },
+ {
+ type: "panel",
+ children: [
+ {
+ type: "heading",
+ props: { level: 3 },
+ content: "More than paragraphs",
+ },
+ {
+ type: "paragraph",
+ content: "Panels can hold any block as their body.",
+ },
+ {
+ type: "paragraph",
+ content:
+ "Try pressing '/' inside this panel to add a heading or code block.",
+ },
+ ],
+ },
+ {
+ type: "paragraph",
+ content: "Press '/' anywhere to insert a new Panel.",
+ },
+ {
+ type: "paragraph",
+ },
+ ],
+ });
+
+ return (
+
+
+ filterSuggestionItems(
+ [...getDefaultReactSlashMenuItems(editor), insertPanel(editor)],
+ query,
+ )
+ }
+ />
+
+ );
+}
diff --git a/examples/06-custom-schema/09-container-block/src/Panel.tsx b/examples/06-custom-schema/09-container-block/src/Panel.tsx
new file mode 100644
index 0000000000..7af68eff48
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/src/Panel.tsx
@@ -0,0 +1,16 @@
+import { createReactBlockSpec } from "@blocknote/react";
+
+import "./styles.css";
+
+export const createPanel = createReactBlockSpec(
+ {
+ type: "panel",
+ propSchema: {},
+ content: "none",
+ children: { allow: "blocks" },
+ },
+ {
+ // With no content of its own, contentRef receives the child blocks.
+ render: (props) => ,
+ },
+);
diff --git a/examples/06-custom-schema/09-container-block/src/styles.css b/examples/06-custom-schema/09-container-block/src/styles.css
new file mode 100644
index 0000000000..ed2f1b8b6b
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/src/styles.css
@@ -0,0 +1,10 @@
+.panel {
+ border-left: 4px solid #507aff;
+ border-radius: 6px;
+ background-color: var(--panel-bg, #e6ebff);
+ padding: 12px 16px;
+}
+
+[data-color-scheme="dark"] .panel {
+ --panel-bg: #1e2a5c;
+}
diff --git a/examples/06-custom-schema/09-container-block/tsconfig.json b/examples/06-custom-schema/09-container-block/tsconfig.json
new file mode 100644
index 0000000000..2aa62c56e6
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/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/09-container-block/vite-env.d.ts b/examples/06-custom-schema/09-container-block/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/06-custom-schema/09-container-block/vite.config.ts b/examples/06-custom-schema/09-container-block/vite.config.ts
new file mode 100644
index 0000000000..cbf6ff2ffc
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/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/examples/06-custom-schema/09-math-block/vite.config.ts b/examples/06-custom-schema/09-math-block/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/09-math-block/vite.config.ts
+++ b/examples/06-custom-schema/09-math-block/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/06-custom-schema/10-diagram-block/vite.config.ts b/examples/06-custom-schema/10-diagram-block/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/10-diagram-block/vite.config.ts
+++ b/examples/06-custom-schema/10-diagram-block/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/06-custom-schema/11-source-with-preview/vite.config.ts b/examples/06-custom-schema/11-source-with-preview/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/11-source-with-preview/vite.config.ts
+++ b/examples/06-custom-schema/11-source-with-preview/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/06-custom-schema/13-callout-block/.bnexample.json b/examples/06-custom-schema/13-callout-block/.bnexample.json
new file mode 100644
index 0000000000..3de7330631
--- /dev/null
+++ b/examples/06-custom-schema/13-callout-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/13-callout-block/README.md b/examples/06-custom-schema/13-callout-block/README.md
new file mode 100644
index 0000000000..20071bb974
--- /dev/null
+++ b/examples/06-custom-schema/13-callout-block/README.md
@@ -0,0 +1,19 @@
+# Callout Block
+
+In this example, we create a custom `Callout` block with a real rich-text title and a body of child blocks (a titled block), like a Notion-style callout.
+
+The block combines `content: "inline"` with the `children` config on `BlockConfig`. The title is ordinary inline content — formatting, links, and multiplayer cursors all work — while `children: { allow: "blocks" }` hosts the body blocks, which live on `block.children` at runtime. `render` draws the title row and `renderFrame` draws the box around the title and body together.
+
+We also wire up a Slash Menu item to insert the callout.
+
+**Try it out:**
+
+- Press Enter at the end of the callout's title to jump into its body.
+- Press Backspace at the start of the first body block to merge it back into the title.
+- Press "/" inside the body and add a code block, heading, or list.
+
+**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/13-callout-block/index.html b/examples/06-custom-schema/13-callout-block/index.html
new file mode 100644
index 0000000000..389f1cfc66
--- /dev/null
+++ b/examples/06-custom-schema/13-callout-block/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+ Callout Block
+
+
+
+
+
+
+
diff --git a/examples/06-custom-schema/13-callout-block/main.tsx b/examples/06-custom-schema/13-callout-block/main.tsx
new file mode 100644
index 0000000000..1260513388
--- /dev/null
+++ b/examples/06-custom-schema/13-callout-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/13-callout-block/package.json b/examples/06-custom-schema/13-callout-block/package.json
new file mode 100644
index 0000000000..9a61db5e93
--- /dev/null
+++ b/examples/06-custom-schema/13-callout-block/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@blocknote/example-custom-schema-callout-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/13-callout-block/src/App.tsx b/examples/06-custom-schema/13-callout-block/src/App.tsx
new file mode 100644
index 0000000000..bb199625b9
--- /dev/null
+++ b/examples/06-custom-schema/13-callout-block/src/App.tsx
@@ -0,0 +1,89 @@
+import { BlockNoteSchema } 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 { RiChatQuoteLine } from "react-icons/ri";
+
+import { createCallout } from "./Callout";
+import "./styles.css";
+
+// Schema with the default blocks plus our custom Callout titled block.
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ callout: createCallout(),
+ },
+});
+
+// Slash menu item to insert a Callout.
+function insertCallout(editor: typeof schema.BlockNoteEditor) {
+ return {
+ title: "Callout",
+ subtext: "Titled container block that wraps other blocks",
+ onItemClick: () =>
+ insertOrUpdateBlockForSlashMenu(editor, {
+ type: "callout",
+ }),
+ aliases: ["callout", "container", "alert", "note", "tip", "info"],
+ group: "Basic blocks",
+ icon: ,
+ };
+}
+
+export default function App() {
+ const editor = useCreateBlockNote({
+ schema,
+ initialContent: [
+ {
+ type: "paragraph",
+ content:
+ "Welcome! This demo shows a titled block: a rich-text title with a body of child blocks.",
+ },
+ {
+ type: "callout",
+ content: "A callout with a real title",
+ children: [
+ {
+ type: "paragraph",
+ content:
+ "The title is ordinary inline content: formatting, links, and multiplayer cursors all work.",
+ },
+ {
+ type: "paragraph",
+ content:
+ "Press Enter at the end of the title to jump into the body, or Backspace at the start of the body to merge back.",
+ },
+ ],
+ },
+ {
+ type: "paragraph",
+ content: "Press '/' anywhere to insert a new Callout.",
+ },
+ {
+ type: "paragraph",
+ },
+ ],
+ });
+
+ return (
+
+
+ filterSuggestionItems(
+ [...getDefaultReactSlashMenuItems(editor), insertCallout(editor)],
+ query,
+ )
+ }
+ />
+
+ );
+}
diff --git a/examples/06-custom-schema/13-callout-block/src/Callout.tsx b/examples/06-custom-schema/13-callout-block/src/Callout.tsx
new file mode 100644
index 0000000000..d26c7eb81a
--- /dev/null
+++ b/examples/06-custom-schema/13-callout-block/src/Callout.tsx
@@ -0,0 +1,32 @@
+import { createReactBlockSpec } from "@blocknote/react";
+
+import "./styles.css";
+
+// The Callout block: a titled block. `content: "inline"` plus `children`
+// gives the block its own rich-text title with child blocks as its body.
+// `render` draws the title row (the title mounts into `contentRef`), while
+// `renderFrame` draws the box around the title and the body together.
+export const createCallout = createReactBlockSpec(
+ {
+ type: "callout",
+ propSchema: {},
+ content: "inline",
+ // The title is ordinary inline content; the children are the body.
+ children: { allow: "blocks" },
+ },
+ {
+ render: (props) => (
+
+
+ !
+
+
+
+ ),
+ renderFrame: (props) => (
+
+
+
+ ),
+ },
+);
diff --git a/examples/06-custom-schema/13-callout-block/src/styles.css b/examples/06-custom-schema/13-callout-block/src/styles.css
new file mode 100644
index 0000000000..c4ca0071da
--- /dev/null
+++ b/examples/06-custom-schema/13-callout-block/src/styles.css
@@ -0,0 +1,42 @@
+.callout {
+ border-radius: 6px;
+ padding: 12px 16px;
+ border-left: 4px solid var(--callout-accent, #507aff);
+ background-color: var(--callout-bg, #e6ebff);
+}
+
+[data-color-scheme="dark"] .callout {
+ --callout-bg: #1e2a5c;
+}
+
+.callout-title-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+
+.callout-badge {
+ flex-shrink: 0;
+ width: 20px;
+ height: 20px;
+ border-radius: 9999px;
+ background-color: var(--callout-accent, #507aff);
+ color: #fff;
+ font-size: 12px;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-top: 2px;
+}
+
+.callout-title {
+ flex-grow: 1;
+ min-width: 0;
+ font-weight: 600;
+}
+
+.callout-slot {
+ min-width: 0;
+}
diff --git a/examples/06-custom-schema/13-callout-block/tsconfig.json b/examples/06-custom-schema/13-callout-block/tsconfig.json
new file mode 100644
index 0000000000..2aa62c56e6
--- /dev/null
+++ b/examples/06-custom-schema/13-callout-block/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/13-callout-block/vite-env.d.ts b/examples/06-custom-schema/13-callout-block/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/examples/06-custom-schema/13-callout-block/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/06-custom-schema/13-callout-block/vite.config.ts b/examples/06-custom-schema/13-callout-block/vite.config.ts
new file mode 100644
index 0000000000..cbf6ff2ffc
--- /dev/null
+++ b/examples/06-custom-schema/13-callout-block/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/examples/06-custom-schema/draggable-inline-content/vite.config.ts b/examples/06-custom-schema/draggable-inline-content/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/draggable-inline-content/vite.config.ts
+++ b/examples/06-custom-schema/draggable-inline-content/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/06-custom-schema/react-custom-blocks/vite.config.ts b/examples/06-custom-schema/react-custom-blocks/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/react-custom-blocks/vite.config.ts
+++ b/examples/06-custom-schema/react-custom-blocks/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/06-custom-schema/react-custom-inline-content/vite.config.ts b/examples/06-custom-schema/react-custom-inline-content/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/react-custom-inline-content/vite.config.ts
+++ b/examples/06-custom-schema/react-custom-inline-content/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/06-custom-schema/react-custom-styles/vite.config.ts b/examples/06-custom-schema/react-custom-styles/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/react-custom-styles/vite.config.ts
+++ b/examples/06-custom-schema/react-custom-styles/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/07-collaboration/01-partykit/vite.config.ts b/examples/07-collaboration/01-partykit/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/07-collaboration/01-partykit/vite.config.ts
+++ b/examples/07-collaboration/01-partykit/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/07-collaboration/02-liveblocks/vite.config.ts b/examples/07-collaboration/02-liveblocks/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/07-collaboration/02-liveblocks/vite.config.ts
+++ b/examples/07-collaboration/02-liveblocks/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/07-collaboration/03-y-sweet/vite.config.ts b/examples/07-collaboration/03-y-sweet/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/07-collaboration/03-y-sweet/vite.config.ts
+++ b/examples/07-collaboration/03-y-sweet/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/07-collaboration/04-electric-sql/vite.config.ts b/examples/07-collaboration/04-electric-sql/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/07-collaboration/04-electric-sql/vite.config.ts
+++ b/examples/07-collaboration/04-electric-sql/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/07-collaboration/05-comments/vite.config.ts b/examples/07-collaboration/05-comments/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/07-collaboration/05-comments/vite.config.ts
+++ b/examples/07-collaboration/05-comments/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/07-collaboration/06-comments-with-sidebar/vite.config.ts b/examples/07-collaboration/06-comments-with-sidebar/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/07-collaboration/06-comments-with-sidebar/vite.config.ts
+++ b/examples/07-collaboration/06-comments-with-sidebar/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/07-collaboration/07-ghost-writer/vite.config.ts b/examples/07-collaboration/07-ghost-writer/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/07-collaboration/07-ghost-writer/vite.config.ts
+++ b/examples/07-collaboration/07-ghost-writer/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/07-collaboration/08-forking/vite.config.ts b/examples/07-collaboration/08-forking/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/07-collaboration/08-forking/vite.config.ts
+++ b/examples/07-collaboration/08-forking/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/07-collaboration/09-comments-testing/vite.config.ts b/examples/07-collaboration/09-comments-testing/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/07-collaboration/09-comments-testing/vite.config.ts
+++ b/examples/07-collaboration/09-comments-testing/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/07-collaboration/10-suggestion-multi-editor/vite.config.ts b/examples/07-collaboration/10-suggestion-multi-editor/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/07-collaboration/10-suggestion-multi-editor/vite.config.ts
+++ b/examples/07-collaboration/10-suggestion-multi-editor/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/07-collaboration/11-versioning-yjs13/vite.config.ts b/examples/07-collaboration/11-versioning-yjs13/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/07-collaboration/11-versioning-yjs13/vite.config.ts
+++ b/examples/07-collaboration/11-versioning-yjs13/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/07-collaboration/12-multi-doc-versioning/vite.config.ts b/examples/07-collaboration/12-multi-doc-versioning/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/07-collaboration/12-multi-doc-versioning/vite.config.ts
+++ b/examples/07-collaboration/12-multi-doc-versioning/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/07-collaboration/13-versioning-yjs14/vite.config.ts b/examples/07-collaboration/13-versioning-yjs14/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/07-collaboration/13-versioning-yjs14/vite.config.ts
+++ b/examples/07-collaboration/13-versioning-yjs14/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/07-collaboration/14-suggestion-gallery/vite.config.ts b/examples/07-collaboration/14-suggestion-gallery/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/07-collaboration/14-suggestion-gallery/vite.config.ts
+++ b/examples/07-collaboration/14-suggestion-gallery/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/08-extensions/01-tiptap-arrow-conversion/vite.config.ts b/examples/08-extensions/01-tiptap-arrow-conversion/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/08-extensions/01-tiptap-arrow-conversion/vite.config.ts
+++ b/examples/08-extensions/01-tiptap-arrow-conversion/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/08-extensions/02-versioning/vite.config.ts b/examples/08-extensions/02-versioning/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/08-extensions/02-versioning/vite.config.ts
+++ b/examples/08-extensions/02-versioning/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/09-ai/01-minimal/vite.config.ts b/examples/09-ai/01-minimal/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/09-ai/01-minimal/vite.config.ts
+++ b/examples/09-ai/01-minimal/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/09-ai/02-playground/vite.config.ts b/examples/09-ai/02-playground/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/09-ai/02-playground/vite.config.ts
+++ b/examples/09-ai/02-playground/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/09-ai/03-custom-ai-menu-items/vite.config.ts b/examples/09-ai/03-custom-ai-menu-items/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/09-ai/03-custom-ai-menu-items/vite.config.ts
+++ b/examples/09-ai/03-custom-ai-menu-items/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/09-ai/04-with-collaboration/vite.config.ts b/examples/09-ai/04-with-collaboration/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/09-ai/04-with-collaboration/vite.config.ts
+++ b/examples/09-ai/04-with-collaboration/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/09-ai/05-manual-execution/vite.config.ts b/examples/09-ai/05-manual-execution/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/09-ai/05-manual-execution/vite.config.ts
+++ b/examples/09-ai/05-manual-execution/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/09-ai/06-client-side-transport/vite.config.ts b/examples/09-ai/06-client-side-transport/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/09-ai/06-client-side-transport/vite.config.ts
+++ b/examples/09-ai/06-client-side-transport/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/09-ai/07-server-persistence/vite.config.ts b/examples/09-ai/07-server-persistence/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/09-ai/07-server-persistence/vite.config.ts
+++ b/examples/09-ai/07-server-persistence/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/vanilla-js/react-vanilla-custom-blocks/vite.config.ts b/examples/vanilla-js/react-vanilla-custom-blocks/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/vanilla-js/react-vanilla-custom-blocks/vite.config.ts
+++ b/examples/vanilla-js/react-vanilla-custom-blocks/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/vanilla-js/react-vanilla-custom-inline-content/vite.config.ts b/examples/vanilla-js/react-vanilla-custom-inline-content/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/vanilla-js/react-vanilla-custom-inline-content/vite.config.ts
+++ b/examples/vanilla-js/react-vanilla-custom-inline-content/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/vanilla-js/react-vanilla-custom-styles/vite.config.ts b/examples/vanilla-js/react-vanilla-custom-styles/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/vanilla-js/react-vanilla-custom-styles/vite.config.ts
+++ b/examples/vanilla-js/react-vanilla-custom-styles/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/examples/vanilla-js/vanilla-custom-side-menu/vite.config.ts b/examples/vanilla-js/vanilla-custom-side-menu/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/vanilla-js/vanilla-custom-side-menu/vite.config.ts
+++ b/examples/vanilla-js/vanilla-custom-side-menu/vite.config.ts
@@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/",
+ "../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/",
+ "../../../packages/react/src/",
),
} as any),
},
diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts
index 9d695e38b6..c2045121f4 100644
--- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts
@@ -59,9 +59,10 @@ export function insertBlocks<
}
// `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, so it
- // is checked here, where the nodes are known, rather than left to `tr.step`
- // to reject with a ProseMirror-level message.
+ // doesn't exist yet when it runs. The whole fragment still has to fit — a
+ // `blockGroup` takes a paragraph but not a `namedOnly` block — 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))
diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts
index 8da2132a40..d2d1022389 100644
--- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts
@@ -8,12 +8,48 @@ import {
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: "blocks", 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: "blocks" },
+ placeable: "namedOnly",
+ }),
+ } as const,
+});
let editor: BlockNoteEditor;
beforeAll(() => {
- editor = BlockNoteEditor.create() as any;
+ editor = BlockNoteEditor.create({ schema }) as any;
});
afterAll(() => {
@@ -28,21 +64,25 @@ beforeEach(() => {
});
describe('insertBlocks "first-child" / "last-child"', () => {
- it("nests under a childless block, creating the blockGroup", () => {
- expect(editor.getBlock("p-0")!.children).toHaveLength(0);
+ 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" }],
- "p-0",
+ "b-0",
"first-child",
);
editor.insertBlocks(
[{ id: "last", type: "paragraph" }],
- "p-0",
+ "b-0",
"last-child",
);
- expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([
+ expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([
"first",
"last",
]);
@@ -51,63 +91,146 @@ describe('insertBlocks "first-child" / "last-child"', () => {
it("prepends and appends around existing children", () => {
editor.replaceBlocks(editor.document, [
{
- id: "p-0",
- type: "paragraph",
- content: "Paragraph 0",
+ id: "b-0",
+ type: "box",
children: [{ id: "existing", type: "paragraph", content: "Existing" }],
},
+ { id: "trailing", type: "paragraph", content: "" },
]);
editor.insertBlocks(
[{ id: "first", type: "paragraph" }],
- "p-0",
+ "b-0",
"first-child",
);
editor.insertBlocks(
[{ id: "last", type: "paragraph" }],
- "p-0",
+ "b-0",
"last-child",
);
- expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([
+ expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([
"first",
"existing",
"last",
]);
});
- it("still inserts siblings with the default and explicit placements", () => {
- editor.insertBlocks([{ id: "after", type: "paragraph" }], "p-0");
- editor.insertBlocks([{ id: "before", type: "paragraph" }], "p-0", "before");
- editor.insertBlocks([{ id: "sibling", type: "paragraph" }], "p-0", "after");
+ 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: "" },
+ ]);
- expect(editor.document.map((block) => block.id)).toEqual([
- "after",
- "before",
+ // `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",
- "sibling",
+ "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 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(/at "after" of block "c-0": no valid position/);
+ });
+
+ it("throws when only the first of several blocks would fit", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Paragraph 0" },
+ ]);
+
+ // Nesting under a childless regular block wraps the batch in a fresh
+ // `blockGroup`, which takes the paragraph but not the `namedOnly`
+ // cell. Validating only the first node used to let the batch through and
+ // fail later with a raw ProseMirror `ReplaceError`.
+ expect(() =>
+ editor.insertBlocks(
+ [{ type: "paragraph" }, { type: "cell" }],
+ "p-0",
+ "last-child",
+ ),
+ ).toThrow(/at "last-child" of block "p-0": .* doesn't accept them/);
+
+ expect(editor.getBlock("p-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" },
],
- "p-0",
+ "b-0",
"last-child",
);
- expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([
+ expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([
"one",
"two",
]);
});
-
- it("throws when the reference block does not exist", () => {
- expect(() =>
- editor.insertBlocks([{ type: "paragraph" }], "missing-id", "last-child"),
- ).toThrow(/Block with ID missing-id not found/);
- });
});
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 b339f66ec1..532da5aaef 100644
--- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts
@@ -3,6 +3,8 @@ import { describe, expect, it } from "vite-plus/test";
import { getBlockInfoFromSelection } from "../../../getBlockInfoFromPos.js";
import { setupTestEnv } from "../../setupTestEnv.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();
@@ -146,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 7101698a39..e3817aa3a6 100644
--- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
@@ -1,11 +1,45 @@
-import { EditorState } from "prosemirror-state";
+import { Fragment, type Node } from "prosemirror-model";
+import { EditorState, TextSelection } from "prosemirror-state";
import {
+ type BlockInfo,
getBlockInfoAt,
getLastDescendantBlockInfo,
getPrevBlockInfo,
+ getParentBlockInfo,
} from "../../../getBlockInfoFromPos.js";
+/** Returns compatible text to append, or undefined when the blocks cannot merge. */
+export function getMergeContent(
+ current: Extract,
+ next: Extract,
+): Fragment | undefined {
+ const inline =
+ current.contentKind === "inline" && next.contentKind === "inline";
+ const ownedText =
+ current.hasOwnedChildren &&
+ current.content.node.isTextblock &&
+ next.content.node.isTextblock;
+ if (!inline && !ownedText) {
+ return undefined;
+ }
+ if (current.contentKind === "plain") {
+ const type = current.content.node.type;
+ const children: Node[] = [];
+ next.content.node.forEach((child) => {
+ const text =
+ child.type === type.schema.linebreakReplacement
+ ? "\n"
+ : child.textContent;
+ if (text) {
+ children.push(type.schema.text(text, type.allowedMarks(child.marks)));
+ }
+ });
+ return Fragment.from(children);
+ }
+ return next.content.node.content;
+}
+
/**
* Merges the block starting at `posBetweenBlocks` into the block visually
* above it, by deleting the boundary between the two.
@@ -15,9 +49,9 @@ import {
* i.e. its `BlockInfo`'s `block.beforePos`. The block above is found by walking
* back from there.
* @returns A tiptap command that returns `false` (leaving the doc untouched)
- * when the two blocks can't merge: no block above, either side isn't an
- * inline-content block, or the block above is empty (deleting it is handled
- * elsewhere).
+ * when the two blocks can't merge: no compatible text block above, or the
+ * block above is empty (deleting it is handled elsewhere). An owning block
+ * can also merge plain text, dropping formatting that its schema disallows.
*/
export const mergeBlocksCommand =
(posBetweenBlocks: number) =>
@@ -30,42 +64,38 @@ export const mergeBlocksCommand =
}) => {
const nextBlockInfo = getBlockInfoAt(state.doc, posBetweenBlocks);
- const prevBlockInfo = getPrevBlockInfo(
+ const prevSibling = getPrevBlockInfo(
state.doc,
nextBlockInfo.block.beforePos,
);
-
+ const parent = prevSibling
+ ? undefined
+ : getParentBlockInfo(state.doc, nextBlockInfo.block.beforePos);
+ // An owned body's first block can merge into its title. Ordinary nested
+ // blocks still need a preceding sibling; lifting handles their boundary.
+ const prevBlockInfo = prevSibling
+ ? getLastDescendantBlockInfo(prevSibling)
+ : parent?.hasOwnedChildren
+ ? parent
+ : undefined;
if (!prevBlockInfo) {
return false;
}
- // 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(prevBlockInfo);
-
- // 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"
+ !prevBlockInfo.hasContent ||
+ prevBlockInfo.isContentEmpty ||
+ !nextBlockInfo.hasContent
) {
return false;
}
+ const content = getMergeContent(prevBlockInfo, nextBlockInfo);
+ if (content === undefined) {
+ return false;
+ }
- // 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.
+ // Lift children before removing their parent. Tiptap's chainable state
+ // returns the shared transaction, so the lift is included in dispatch.
if (dispatch && nextBlockInfo.children) {
const childBlocksRange = state.doc
.resolve(nextBlockInfo.children.childrenStart)
@@ -86,16 +116,21 @@ export const mergeBlocksCommand =
);
}
- // 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,
- ),
- );
+ if (content !== nextBlockInfo.content.node.content) {
+ state.tr
+ .replaceWith(
+ prevBlockInfo.contentEnd,
+ nextBlockInfo.contentEnd,
+ content,
+ )
+ .setSelection(
+ TextSelection.create(state.tr.doc, prevBlockInfo.contentEnd),
+ );
+ } else {
+ state.tr.delete(prevBlockInfo.contentEnd, nextBlockInfo.contentStart);
+ }
+ dispatch(state.tr);
}
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 f9bba17c3f..2699217643 100644
--- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts
@@ -7,16 +7,25 @@ import {
getBlockInfoFromSelection,
getNodeId,
} from "../../../getBlockInfoFromPos.js";
-import { setupTestEnv } from "../../setupTestEnv.js";
+import { getNodeById } from "../../../nodeUtil.js";
+import { containerSchema } from "../../containers/containers.fixture.js";
+import { setupTestEnv, testDocument } from "../../setupTestEnv.js";
import {
moveBlocksDown,
moveBlocksUp,
moveSelectedBlocksAndSelection,
} from "./moveBlocks.js";
-const getEditor = setupTestEnv();
+const getEditor = setupTestEnv<
+ typeof containerSchema.blockSchema,
+ typeof containerSchema.inlineContentSchema,
+ typeof containerSchema.styleSchema
+>({
+ schema: containerSchema,
+ document: testDocument,
+});
-function makeSelectionSpanContent(selectionType: "text" | "node" | "cell") {
+function makeSelectionSpanContent(selectionType: "text" | "cell") {
const blockInfo = getEditor().transact((tr) => getBlockInfoFromSelection(tr));
if (!blockInfo.hasContent) {
throw new Error(
@@ -36,10 +45,6 @@ function makeSelectionSpanContent(selectionType: "text" | "node" | "cell") {
),
),
);
- } else if (selectionType === "node") {
- editor.transact((tr) =>
- tr.setSelection(NodeSelection.create(tr.doc, content.beforePos)),
- );
} else {
editor.transact((tr) =>
tr.setSelection(
@@ -69,19 +74,30 @@ describe("Test moveSelectedBlockAndSelection", () => {
).toBeTruthy();
});
- it("Node selection", () => {
- getEditor().setTextCursorPosition("image-0");
- makeSelectionSpanContent("node");
-
- moveSelectedBlocksAndSelection(getEditor(), "paragraph-0", "before");
-
- const selection = getEditor().transact((tr) => tr.selection);
- getEditor().setTextCursorPosition("image-0");
- makeSelectionSpanContent("node");
-
- expect(
- selection.eq(getEditor().transact((tr) => tr.selection)),
- ).toBeTruthy();
+ it.each([
+ { type: "image", offset: 1 },
+ { type: "callout", offset: 0 },
+ ] as const)("Node selection: $type", ({ type, offset }) => {
+ const editor = getEditor();
+ editor.insertBlocks([{ id: "selected", type }], "paragraph-1", "after");
+ editor.transact((tr) => {
+ const block = getNodeById("selected", tr.doc)!;
+ tr.setSelection(
+ NodeSelection.create(tr.doc, block.posBeforeNode + offset),
+ );
+ });
+
+ moveSelectedBlocksAndSelection(editor, "paragraph-0", "before");
+
+ expect(editor.document[0].id).toBe("selected");
+ editor.transact((tr) => {
+ const moved = getNodeById("selected", tr.doc)!;
+ expect(
+ tr.selection.eq(
+ NodeSelection.create(tr.doc, moved.posBeforeNode + offset),
+ ),
+ ).toBe(true);
+ });
});
it("Cell selection", () => {
diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts
index 51ed7e6852..1cb6f2a53c 100644
--- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts
@@ -1,4 +1,4 @@
-import type { NodeType } from "prosemirror-model";
+import type { NodeType, Schema } from "prosemirror-model";
import {
NodeSelection,
Selection,
@@ -11,15 +11,38 @@ import { Block } from "../../../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor";
import { BlockIdentifier } from "../../../../schema/index.js";
import {
- getBlockInfoNearPos,
- getBlockInfoAt,
+ isContainerNode,
+ isNamedOnly,
+} from "../../../../schema/blocks/children.js";
+import {
getInsertionPos,
+ getBlockInfoAt,
+ getBlockInfoNearPos,
getNodeId,
} from "../../../getBlockInfoFromPos.js";
import { getNodeById } from "../../../nodeUtil.js";
import { insertBlocks } from "../insertBlocks/insertBlocks.js";
import { removeAndInsertBlocks } from "../replaceBlocks/replaceBlocks.js";
+/**
+ * Dissolves `placeable: "namedOnly"` blocks into their children.
+ *
+ * A `namedOnly` 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) =>
+ isNamedOnly(pmSchema.nodes[block.type])
+ ? dissolveContainerOnlyBlocks(block.children, pmSchema)
+ : [block],
+ );
+}
+
type BlockSelectionData = (
| {
type: "text";
@@ -114,7 +137,11 @@ function updateBlockSelectionFromData(
anchorBlockPos + data.headCellOffset,
);
} else if (data.type === "node") {
- selection = NodeSelection.create(tr.doc, anchorBlockPos + 1);
+ const blockInfo = getBlockInfoAt(tr.doc, anchorBlockPos);
+ selection = NodeSelection.create(
+ tr.doc,
+ blockInfo.hasContent ? blockInfo.content.beforePos : anchorBlockPos,
+ );
} else {
const headBlockPos = getNodeById(data.headBlockId, tr.doc)?.posBeforeNode;
if (headBlockPos === undefined) {
@@ -133,16 +160,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.
@@ -171,10 +188,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,
);
@@ -209,295 +226,125 @@ export function moveSelectedBlocksAndSelection(
});
}
-/**
- * All a placement check needs to know about the block being moved: where it
- * currently sits, and what would land at the destination. Neither changes as a
- * placement search walks the document, so both are resolved once up front.
- */
-type MovedBlock = {
- /** The moved block's ID, to locate it in the doc. */
- id: string;
- /**
- * The PM node type that would actually be inserted: a child-holding wrapper
- * block (e.g. a `columnList`) goes in as its own node type; anything else
- * as a generic `blockContainer` wrapper.
- */
- nodeType: NodeType;
-};
-
-function toMovedBlock(
+/** The first node type inserted after dissolving container-only blocks. */
+function getMovedNodeType(
editor: BlockNoteEditor,
block: Block,
-): MovedBlock {
- const type = editor.pmSchema.nodes[block.type];
-
- return {
- id: block.id,
- nodeType:
- type && type.isInGroup("bnBlock") && type.isInGroup("childContainer")
- ? type
- : editor.pmSchema.nodes["blockContainer"],
- };
-}
-
-// Checks if a block would be in a valid place after being moved
-// before/after `referenceBlock`. A regular block nests under any block (it
-// goes into that block's `blockGroup`), but a wrapper 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: MovedBlock,
-): boolean {
- 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,
- getBlockInfoAt(tr.doc, posInfo.posBeforeNode),
- placement,
- movedBlock.nodeType,
- );
- return target !== null;
- });
-}
-
-/**
- * Gets the placement for moving a block up. This has 3 cases:
- * 1. If the block has a previous sibling without children, the placement is
- * before it.
- * 2. If the block has a previous sibling with children, the placement is after
- * the last child.
- * 3. If the block has no previous sibling, but is nested, the placement is
- * before its parent.
- * If the placement is invalid, the function is called recursively until a valid
- * placement is found. Returns undefined if no valid placement is found, meaning
- * the block is already at the top of the document.
- *
- * @param movedBlock What is being moved (see {@link MovedBlock}). Carried
- * through the recursion because "is this placement valid?" depends on it: a
- * candidate destination has to accept the moved node's type. Only read by
- * `checkPlacementIsValid`.
- * @param prevBlock The candidate previous sibling, i.e. the block the
- * placement is measured against. Steps further back on each recursion.
- * @param parentBlock The parent of `prevBlock`'s level, used for case 3.
- */
-function getMoveUpPlacement(
- editor: BlockNoteEditor,
- movedBlock: MovedBlock,
- prevBlock?: Block,
- parentBlock?: Block,
-):
- | { referenceBlock: BlockIdentifier; placement: "before" | "after" }
- | undefined {
- let referenceBlock: Block | undefined;
- let placement: "before" | "after" | undefined;
-
- if (!prevBlock) {
- if (parentBlock) {
- referenceBlock = parentBlock;
- placement = "before";
- }
- } else if (prevBlock.children.length > 0) {
- referenceBlock = prevBlock.children[prevBlock.children.length - 1];
- placement = "after";
- } else {
- referenceBlock = prevBlock;
- placement = "before";
- }
-
- // Case when the block is already at the top of the document.
- if (!referenceBlock || !placement) {
- return undefined;
- }
-
- if (!checkPlacementIsValid(editor, referenceBlock, placement, movedBlock)) {
- const referenceBlockParent = editor.getParentBlock(referenceBlock);
- return getMoveUpPlacement(
- editor,
- movedBlock,
- placement === "after"
- ? referenceBlock
- : editor.getPrevBlock(referenceBlock),
- referenceBlockParent,
- );
- }
-
- return { referenceBlock, placement };
+): NodeType {
+ const first = dissolveContainerOnlyBlocks([block], editor.pmSchema)[0];
+ const type = first && editor.pmSchema.nodes[first.type];
+ return type && isContainerNode(type)
+ ? type
+ : editor.pmSchema.nodes["blockContainer"];
}
/**
- * Gets the placement for moving a block down. This has 3 cases:
- * 1. If the block has a next sibling without children, the placement is after
- * it.
- * 2. If the block has a next sibling with children, the placement is before the
- * first child.
- * 3. If the block has no next sibling, but is nested, the placement is
- * after its parent.
- * If the placement is invalid, the function is called recursively until a valid
- * placement is found. Returns undefined if no valid placement is found, meaning
- * the block is already at the bottom of the document.
- *
- * @param movedBlock What is being moved; see `getMoveUpPlacement`.
- * @param nextBlock The candidate next sibling, i.e. the block the placement is
- * measured against. Steps further forward on each recursion.
- * @param parentBlock The parent of `nextBlock`'s level, used for case 3.
+ * Searches in document order for a placement accepted by the moved node's type.
+ * Moving past a sibling with children enters its nearest child; reaching the
+ * end of a sibling list moves outside its parent.
*/
-function getMoveDownPlacement(
+function getMovePlacement(
editor: BlockNoteEditor,
- movedBlock: MovedBlock,
- nextBlock?: Block,
- parentBlock?: Block,
+ nodeType: NodeType,
+ direction: "up" | "down",
+ sibling?: Block,
+ parent?: Block,
):
| { referenceBlock: BlockIdentifier; placement: "before" | "after" }
| undefined {
- let referenceBlock: Block | undefined;
- let placement: "before" | "after" | undefined;
-
- if (!nextBlock) {
- if (parentBlock) {
- referenceBlock = parentBlock;
- placement = "after";
+ const outside = direction === "up" ? "before" : "after";
+ const inside = direction === "up" ? "after" : "before";
+ while (sibling || parent) {
+ const hasChildren = sibling && sibling.children.length > 0;
+ const referenceBlock = sibling
+ ? hasChildren
+ ? sibling.children[direction === "up" ? sibling.children.length - 1 : 0]
+ : sibling
+ : parent!;
+ const placement = hasChildren ? inside : outside;
+ const valid = editor.transact((tr) => {
+ const target = getNodeById(referenceBlock.id, tr.doc);
+ return (
+ target !== undefined &&
+ getInsertionPos(
+ tr.doc,
+ getBlockInfoAt(tr.doc, target.posBeforeNode),
+ placement,
+ nodeType,
+ ) !== null
+ );
+ });
+ if (valid) {
+ return { referenceBlock, placement };
}
- } else if (nextBlock.children.length > 0) {
- referenceBlock = nextBlock.children[0];
- placement = "before";
- } else {
- referenceBlock = nextBlock;
- placement = "after";
- }
-
- // Case when the block is already at the bottom of the document.
- if (!referenceBlock || !placement) {
- return undefined;
- }
-
- if (!checkPlacementIsValid(editor, referenceBlock, placement, movedBlock)) {
- const referenceBlockParent = editor.getParentBlock(referenceBlock);
- return getMoveDownPlacement(
- editor,
- movedBlock,
- placement === "before"
+ parent = editor.getParentBlock(referenceBlock);
+ sibling =
+ placement === inside
? referenceBlock
- : editor.getNextBlock(referenceBlock),
- referenceBlockParent,
- );
+ : direction === "up"
+ ? editor.getPrevBlock(referenceBlock)
+ : editor.getNextBlock(referenceBlock);
}
-
- return { referenceBlock, placement };
+ return undefined;
}
-export function moveBlocksUp(
+function moveBlocksInDirection(
editor: BlockNoteEditor,
+ direction: "up" | "down",
blockIdentifier?: BlockIdentifier,
) {
editor.transact(() => {
- let sourceBlock: Block | undefined;
+ let blocks: Block[];
if (blockIdentifier) {
- sourceBlock = editor.getBlock(blockIdentifier);
- if (!sourceBlock) {
+ const block = editor.getBlock(blockIdentifier);
+ if (!block) {
return;
}
+ blocks = [block];
} else {
- const selection = editor.getSelection();
- sourceBlock =
- selection?.blocks[0] || editor.getTextCursorPosition().block;
+ blocks = editor.getSelection()?.blocks || [
+ editor.getTextCursorPosition().block,
+ ];
}
- const moveUpPlacement = getMoveUpPlacement(
+ // The last selected block anchors a downward move, but insertion always
+ // starts with the first selected block.
+ const sourceBlock = blocks[direction === "up" ? 0 : blocks.length - 1];
+ const target = getMovePlacement(
editor,
- // `moveBlocks` inserts the flattened selection (a `column` goes in as
- // its children), so the placement is validated for the block that
- // actually lands at the destination, not for the raw block.
- toMovedBlock(editor, flattenColumns([sourceBlock])[0] ?? sourceBlock),
- editor.getPrevBlock(sourceBlock),
+ getMovedNodeType(editor, blocks[0]),
+ direction,
+ direction === "up"
+ ? editor.getPrevBlock(sourceBlock)
+ : editor.getNextBlock(sourceBlock),
editor.getParentBlock(sourceBlock),
);
-
- if (!moveUpPlacement) {
+ if (!target) {
return;
}
if (blockIdentifier) {
- moveBlocks(
- editor,
- [sourceBlock],
- moveUpPlacement.referenceBlock,
- moveUpPlacement.placement,
- );
+ moveBlocks(editor, blocks, target.referenceBlock, target.placement);
} else {
moveSelectedBlocksAndSelection(
editor,
- moveUpPlacement.referenceBlock,
- moveUpPlacement.placement,
+ target.referenceBlock,
+ target.placement,
);
}
});
}
-export function moveBlocksDown(
+export function moveBlocksUp(
editor: BlockNoteEditor,
blockIdentifier?: BlockIdentifier,
) {
- 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,
- // See `moveBlocksUp`: validate for the flattened block that actually
- // lands at the destination.
- toMovedBlock(
- editor,
- flattenColumns([firstMovedBlock])[0] ?? firstMovedBlock,
- ),
- editor.getNextBlock(sourceBlock),
- editor.getParentBlock(sourceBlock),
- );
-
- if (!moveDownPlacement) {
- return;
- }
+ moveBlocksInDirection(editor, "up", blockIdentifier);
+}
- if (blockIdentifier) {
- moveBlocks(
- editor,
- [sourceBlock],
- moveDownPlacement.referenceBlock,
- moveDownPlacement.placement,
- );
- } else {
- moveSelectedBlocksAndSelection(
- editor,
- moveDownPlacement.referenceBlock,
- moveDownPlacement.placement,
- );
- }
- });
+export function moveBlocksDown(
+ editor: BlockNoteEditor,
+ blockIdentifier?: BlockIdentifier,
+) {
+ moveBlocksInDirection(editor, "down", blockIdentifier);
}
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..cf24ec0556 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,223 @@ 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);
+ });
+});
+
+// A `grid` holds only `gridCell`s, so a selection spanning two cells has no
+// nestable range inside the grid. The range has to resolve outside it, at the
+// `blockGroup` the grid sits in, so Tab moves the grid as a unit rather than
+// doing nothing. `columnList`/`column` in `@blocknote/xl-multi-column` are the
+// same shape, and the user-facing case this guards.
+describe("Nesting a selection that spans two of a container's children", () => {
+ const withContainerEditor = setupContainerNestTestEnv();
+
+ function gridWith(id: string) {
+ return {
+ id,
+ type: "grid" as const,
+ children: [
+ {
+ id: `${id}-cell-a`,
+ type: "gridCell" as const,
+ children: [
+ { id: `${id}-a`, type: "paragraph" as const, content: "A" },
+ ],
+ },
+ {
+ id: `${id}-cell-b`,
+ type: "gridCell" as const,
+ children: [
+ { id: `${id}-b`, type: "paragraph" as const, content: "B" },
+ ],
+ },
+ ],
+ };
+ }
+
+ it("Nests the whole grid under its previous sibling", () => {
+ const editor = withContainerEditor([
+ { id: "paragraph-0", type: "paragraph", content: "Paragraph 0" },
+ gridWith("grid-0"),
+ ]);
+
+ editor.setSelection("grid-0-a", "grid-0-b");
+
+ expect(editor.canNestBlock()).toBe(true);
+ editor.nestBlock();
+
+ expect(editor.document.map((block) => block.id)).toEqual(["paragraph-0"]);
+ expect(editor.getBlock("paragraph-0")!.children.map((c) => c.id)).toEqual([
+ "grid-0",
+ ]);
+ // The grid itself is untouched — only its position changed.
+ expect(editor.getBlock("grid-0")!.children.map((c) => c.id)).toEqual([
+ "grid-0-cell-a",
+ "grid-0-cell-b",
+ ]);
+ });
+
+ it("Unnests the whole grid out of its parent", () => {
+ const editor = withContainerEditor([
+ {
+ id: "paragraph-0",
+ type: "paragraph",
+ content: "Paragraph 0",
+ children: [gridWith("grid-0")],
+ },
+ ]);
+
+ editor.setSelection("grid-0-a", "grid-0-b");
+
+ expect(editor.canUnnestBlock()).toBe(true);
+ editor.unnestBlock();
+
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "paragraph-0",
+ "grid-0",
+ ]);
+ expect(editor.getBlock("paragraph-0")!.children).toEqual([]);
+ expect(editor.getBlock("grid-0")!.children.map((c) => c.id)).toEqual([
+ "grid-0-cell-a",
+ "grid-0-cell-b",
+ ]);
+ });
+
+ it("Reports no nesting when the grid has no previous sibling", () => {
+ const editor = withContainerEditor([gridWith("grid-0")]);
+
+ editor.setSelection("grid-0-a", "grid-0-b");
+
+ const before = editor.document;
+ expect(editor.canNestBlock()).toBe(false);
+ editor.nestBlock();
+ 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 55f7200f3f..7dc8fa9aa7 100644
--- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
@@ -3,6 +3,11 @@ import { Transaction } from "prosemirror-state";
import { canJoin, liftTarget, ReplaceAroundStep } from "prosemirror-transform";
import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
+import {
+ CHILD_CONTAINER_GROUP,
+ hasOwnedChildren,
+} from "../../../../schema/blocks/children.js";
+
/**
* Whether `node` is the sibling list that nesting and unnesting operate on: a
* node that holds child blocks, and can hold the kind of node being moved.
@@ -18,7 +23,7 @@ import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
function holdsItems(node: Node, itemType: NodeType) {
return (
node.childCount > 0 &&
- node.type.isInGroup("childContainer") &&
+ node.type.isInGroup(CHILD_CONTAINER_GROUP) &&
node.type.contentMatch.matchType(itemType) !== null
);
}
@@ -182,7 +187,14 @@ export function liftItem(
return false;
}
- if ($from.node(range.depth - 1).type === itemType) {
+ const parent = $from.node(range.depth - 1);
+ // A titled block's body belongs to the block that owns it, so unnesting
+ // stops at its edge rather than lifting the block out of it.
+ if (parent.type === itemType && hasOwnedChildren(parent)) {
+ return false;
+ }
+
+ if (parent.type === itemType) {
// Inside a parent node
return liftToOuterList(tr, itemType, groupType, range); // change 2
}
@@ -208,8 +220,8 @@ export function unnestBlock(editor: BlockNoteEditor) {
// `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 block before the
-// cursor enabled the button while `nestBlock` did nothing.
+// 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));
}
diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts
index d9e1e72981..5d693850ee 100644
--- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts
@@ -1,7 +1,10 @@
import { type Node } from "prosemirror-model";
import { type Transaction } from "prosemirror-state";
import type { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js";
-import { getNodeId } from "../../../getBlockInfoFromPos.js";
+import {
+ getNodeId,
+ getAncestorContainers,
+} from "../../../getBlockInfoFromPos.js";
import type {
BlockIdentifier,
BlockSchema,
@@ -11,7 +14,7 @@ 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";
export function removeAndInsertBlocks<
BSchema extends BlockSchema,
@@ -22,7 +25,7 @@ export function removeAndInsertBlocks<
blocksToRemove: BlockIdentifier[],
blocksToInsert: PartialBlock[],
options: {
- fixColumns?: boolean;
+ fixContainers?: boolean;
} = {},
): {
insertedBlocks: Block[];
@@ -43,13 +46,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 +84,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` container that unwraps
+ // when emptied). 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 +126,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/__snapshots__/splitBlock.test.ts.snap b/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap
index 8cd297eaee..f76031684d 100644
--- a/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap
+++ b/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap
@@ -621,23 +621,6 @@ exports[`Test splitBlocks > Block has children 1`] = `
},
"type": "paragraph",
},
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Para",
- "type": "text",
- },
- ],
- "id": "paragraph-with-children",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
{
"children": [
{
@@ -676,6 +659,23 @@ exports[`Test splitBlocks > Block has children 1`] = `
"type": "paragraph",
},
],
+ "content": [
+ {
+ "styles": {},
+ "text": "Para",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-children",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
"content": [
{
"styles": {},
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 b403aec535..670839a66a 100644
--- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts
@@ -8,6 +8,7 @@ import {
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";
@@ -147,3 +148,185 @@ 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("Keeps the block's children on the first 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 stay with the first half, as they do at the top level.
+ expect(callout.children[2].children).toEqual([]);
+ expect(callout.children[1].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);
+ });
+});
+
+describe("split child ownership", () => {
+ it.each(["start", "middle", "end"] as const)(
+ "keeps children with the intended half when splitting at the %s",
+ (where) => {
+ const editor = getEditor();
+ const id = "paragraph-with-children";
+ const children = editor.getBlock(id)!.children;
+ const target = getNodeById(id, editor.prosemirrorState.doc)!;
+ const info = getBlockInfoFromNode(target.node, target.posBeforeNode);
+ if (!info.hasContent) {
+ throw new Error("Expected content block");
+ }
+ const offset =
+ where === "start"
+ ? 0
+ : where === "end"
+ ? info.contentEnd - info.contentStart
+ : 4;
+ setSelectionWithOffset(editor.prosemirrorState.doc, id, offset);
+ splitBlock(editor.prosemirrorState.selection.from);
+ const index = editor.document.findIndex((block) => block.id === id);
+ const [first, second] = editor.document.slice(index, index + 2);
+ expect((where === "start" ? second : first).children).toEqual(children);
+ expect((where === "start" ? first : second).children).toEqual([]);
+ expect(editor.getTextCursorPosition().block.id).toBe(second.id);
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ },
+ );
+});
diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
index d5229da6bf..74ffcd7067 100644
--- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
@@ -55,7 +55,23 @@ export const splitBlockTr = (
},
];
+ // At the start, children follow the entire title to the second block.
+ // Otherwise they belong to the first half, all within the same undo step.
+ const children = posInBlock === info.contentStart ? undefined : info.children;
+ if (children) {
+ tr.delete(children.beforePos, children.afterPos);
+ }
tr.split(posInBlock, 2, types);
+ if (children) {
+ const original = tr.doc.nodeAt(info.block.beforePos);
+ if (!original?.firstChild) {
+ throw new Error("Split lost its original block");
+ }
+ tr.insert(
+ info.block.beforePos + 1 + original.firstChild.nodeSize,
+ children.node,
+ );
+ }
return true;
};
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 77d2cad826..40a2537cd9 100644
--- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts
@@ -2,13 +2,8 @@ import { describe, expect, it } from "vite-plus/test";
import type { PartialBlock } from "../../../../blocks/defaultBlocks.js";
import { getBlockInfoFromNode } from "../../../getBlockInfoFromPos.js";
-
-// Adapter over the renamed producer: `getNodeById` already returns the
-// `{ node, posBeforeNode }` pair it takes.
-function getBlockInfo(posInfo: { node: any; posBeforeNode: number }) {
- return getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode);
-}
import { getNodeById } from "../../../nodeUtil.js";
+import { containerSchema } from "../../containers/containers.fixture.js";
import { setupTestEnv } from "../../setupTestEnv.js";
import { updateBlock } from "./updateBlock.js";
@@ -183,9 +178,11 @@ 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.hasContent) {
throw new Error("heading-with-everything is not a block container");
@@ -212,9 +209,11 @@ 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.hasContent) {
throw new Error("heading-with-everything is not a block container");
@@ -242,9 +241,11 @@ 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.hasContent) {
throw new Error("heading-with-everything is not a block container");
@@ -275,9 +276,8 @@ 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.hasContent) {
throw new Error("table-0 is not a block container");
@@ -305,9 +305,8 @@ 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.hasContent) {
throw new Error("table-0 is not a block container");
@@ -940,12 +939,11 @@ 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,
- )!,
- );
+ 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");
}
@@ -982,3 +980,257 @@ 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("Fills a container's children when there is no content to carry", () => {
+ const editor = getContainerEditor();
+ editor.transact((tr) =>
+ updateBlock(tr, "empty-paragraph", { type: "pair" }),
+ );
+
+ // An empty paragraph carries nothing, so the rebuilt node holds just the
+ // empty fill its content expression requires — two paragraphs for the
+ // pair's `min: 2`.
+ const block = editor.document[1] as any;
+ expect(block.type).toBe("pair");
+ expect(block.children).toHaveLength(2);
+ expect(block.children.map((child: any) => child.content)).toEqual([[], []]);
+ 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.each(["paragraph-with-text", "callout-0"])(
+ "Rejects conversion of %s rather than dropping incompatible children",
+ (id) => {
+ const editor = getContainerEditor();
+ const before = editor.prosemirrorState.doc;
+ expect(() =>
+ editor.transact((tr) => updateBlock(tr, id, { type: "grid" })),
+ ).toThrow();
+ expect(editor.prosemirrorState.doc.eq(before)).toBe(true);
+ },
+ );
+
+ it("Allows explicit replacement children when converting to a restricted container", () => {
+ const editor = getContainerEditor();
+ editor.transact((tr) =>
+ updateBlock(tr, "callout-0", {
+ type: "grid",
+ children: [
+ {
+ type: "gridCell",
+ children: [{ type: "paragraph", content: "Replacement" }],
+ },
+ { type: "gridCell", children: [{ type: "paragraph" }] },
+ ],
+ }),
+ );
+ expect(editor.document[4].type).toBe("grid");
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("Keeps existing children when a container drops the content", () => {
+ const editor = getContainerEditor();
+ // The carried content has nowhere to go, but the block's own children are
+ // regular blocks the pair can still hold.
+ expect(() =>
+ editor.transact((tr) =>
+ updateBlock(tr, "paragraph-with-text-and-children", {
+ type: "pair",
+ }),
+ ),
+ ).not.toThrow();
+
+ const block = editor.document[2] as any;
+ expect(block.type).toBe("pair");
+ // The kept child survives; the pair's `min: 2` is met by an empty fill.
+ expect(block.children.map((child: any) => child.id)).toContain(
+ "existing-child",
+ );
+ expect(block.children).toHaveLength(2);
+ expect(
+ block.children.find((child: any) => child.id === "existing-child")
+ .content,
+ ).toEqual([{ type: "text", text: "Existing child", styles: {} }]);
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("Keeps a container's children when changing between container types", () => {
+ const editor = getContainerEditor();
+ editor.transact((tr) => updateBlock(tr, "callout-0", { type: "pair" }));
+
+ // Neither container holds content of its own, so the children move across
+ // untouched rather than being re-seeded; the pair's `min: 2` is met by an
+ // empty fill.
+ const block = editor.document[4] as any;
+ expect(block.type).toBe("pair");
+ expect(block.children.map((child: any) => child.id)).toContain(
+ "callout-child",
+ );
+ expect(block.children).toHaveLength(2);
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("Keeps a container's children alongside the content it gains", () => {
+ const editor = getContainerEditor();
+ editor.transact((tr) =>
+ updateBlock(tr, "callout-0", {
+ type: "heading",
+ content: "Now a heading",
+ }),
+ );
+
+ // The container had nowhere to put inline content; the heading does, so
+ // the given content lands there and the children stay nested under it.
+ const block = editor.document[4] as any;
+ expect(block.type).toBe("heading");
+ expect(block.content).toEqual([
+ { type: "text", text: "Now a heading", styles: {} },
+ ]);
+ expect(block.children.map((child: any) => child.id)).toEqual([
+ "callout-child",
+ ]);
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("Updates a container's props without rebuilding it", () => {
+ const editor = getContainerEditor();
+ editor.transact((tr) =>
+ updateBlock(tr, "callout-0", { props: { flavor: "warning" } }),
+ );
+
+ // No shape change, so this is an in-place attribute update: the container
+ // keeps its ID (unlike the type changes above) and its children.
+ const block = editor.document[4] as any;
+ expect(block.id).toBe("callout-0");
+ expect(block.props.flavor).toBe("warning");
+ expect(block.children.map((child: any) => child.id)).toEqual([
+ "callout-child",
+ ]);
+ 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 e487f99fd4..15caf1dca1 100644
--- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
@@ -27,7 +27,8 @@ import {
} 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 } from "../../../../schema/blocks/children.js";
// for compatibility with tiptap. TODO: remove as we want to remove dependency on tiptap command interface
export const updateBlockCommand = <
@@ -70,10 +71,10 @@ export function updateBlockTr<
const stepsBefore = tr.mapping.maps.length;
const blockInfo = getBlockInfoAt(tr.doc, posBeforeBlock);
- let cellAnchor: CellAnchor | null = null;
- if (blockInfo.blockNoteType === "table") {
- cellAnchor = captureCellAnchor(tr);
- }
+ const cellAnchor =
+ blockInfo.hasContent && blockInfo.blockNoteType === "table"
+ ? captureCellAnchor(tr)
+ : null;
const pmSchema = getPmSchema(tr);
@@ -85,8 +86,6 @@ export function updateBlockTr<
throw new Error("Invalid replaceFromPos or replaceToPos");
}
- // Adds blockGroup node with child blocks if necessary.
-
const newBlockType = block.type || blockInfo.blockNoteType;
const newNodeType = pmSchema.nodes[newBlockType];
const newBnBlockNodeType = newNodeType.isInGroup("bnBlock")
@@ -109,27 +108,56 @@ export function updateBlockTr<
? 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
- // wrapper), 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.
-
- // 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.block.node, tr.doc);
+ // Rebuild when the block's shape changes or a container's existing children
+ // cannot satisfy the new type (e.g. changing to a pair requiring two children).
+ if (
+ blockInfo.hasContent !== newNodeType.isInGroup("blockContent") ||
+ (!blockInfo.hasContent &&
+ !newNodeType.validContent(blockInfo.block.node.content))
+ ) {
+ const existingBlock: Block = nodeToBlock(
+ blockInfo.block.node,
+ tr.doc,
+ );
+ const targetConfig = getBlockSchema(pmSchema)[newBlockType];
+ let content: PartialBlock["content"];
+ const children: PartialBlock[] = [...existingBlock.children];
+ if (Array.isArray(existingBlock.content) && existingBlock.content.length) {
+ if (
+ targetConfig.content === "inline" ||
+ targetConfig.content === "plain"
+ ) {
+ content = existingBlock.content;
+ } else if (targetConfig.children !== undefined) {
+ children.unshift({ type: "paragraph", content: existingBlock.content });
+ }
+ }
+
const replacementNode = blockToNode(
{
- children: existingBlock.children, // if no children are passed in, use existing children
+ ...(content ? { content } : {}),
+ // Omit empty children so a new container can seed its required children.
+ ...(children.length > 0 ? { children } : {}),
...block,
},
pmSchema,
);
replacementNode.check(); // `blockToNode` is lenient; validate before mutating the doc
+
+ // Validate the parent too: a valid column still cannot replace a root block.
+ const $oldPos = tr.doc.resolve(blockInfo.block.beforePos);
+ if (
+ !$oldPos.parent.canReplace(
+ $oldPos.index(),
+ $oldPos.index(),
+ Fragment.from(replacementNode),
+ )
+ ) {
+ throw new Error(
+ `Cannot update block to "${newBlockType}": a "${$oldPos.parent.type.name}" doesn't accept it`,
+ );
+ }
+
tr.replaceWith(
blockInfo.block.beforePos,
blockInfo.block.afterPos,
@@ -163,8 +191,12 @@ export function updateBlockTr<
...block.props,
});
- if (cellAnchor) {
- restoreCellAnchor(tr, blockInfo, cellAnchor, stepsBefore);
+ if (cellAnchor && blockInfo.hasContent) {
+ restoreCellAnchor(
+ tr,
+ tr.mapping.slice(stepsBefore).map(blockInfo.content.beforePos),
+ cellAnchor,
+ );
}
}
@@ -177,12 +209,7 @@ function updateBlockContentNode<
tr: Transform,
oldNodeType: NodeType,
newNodeType: NodeType,
- blockInfo: {
- children?:
- | { node: PMNode; beforePos: number; afterPos: number }
- | undefined;
- content: { node: PMNode; beforePos: number; afterPos: number };
- },
+ blockInfo: Extract,
replaceFromOffset?: number,
replaceToOffset?: number,
) {
@@ -211,7 +238,7 @@ 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.
+ // 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
@@ -235,7 +262,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.
//
@@ -520,11 +547,10 @@ function updateChildren<
return node;
});
- // Checks if a blockGroup node already exists.
if (blockInfo.children) {
- // Replaces the child nodes in the existing blockGroup, only touching the
- // range that actually changed (keeping unchanged leading/trailing
- // children untouched).
+ // 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.children.beforePos,
@@ -532,11 +558,12 @@ function updateChildren<
);
} else if (blockInfo.hasContent) {
// A `blockContainer` with no children yet: its `blockGroup` is lazy
- // (`blockContent blockGroup?`), so insert a new one after the content
- // node.
+ // (`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.content.afterPos,
- pmSchema.nodes["blockGroup"].createChecked({}, childNodes),
+ createBlockGroup(pmSchema, childNodes),
);
}
}
@@ -639,34 +666,10 @@ export function captureCellAnchor(tr: Transform): CellAnchor | null {
function restoreCellAnchor(
tr: Transform | Transaction,
- blockInfo: BlockInfo,
+ tablePos: number,
a: CellAnchor,
- stepsBefore: number,
): boolean {
- if (blockInfo.blockNoteType !== "table") {
- return false;
- }
-
- // 1) Resolve the table node in the current document
- let tablePos = -1;
-
- if (blockInfo.hasContent) {
- // Prefer the content position when available (points directly at the PM table node)
- tablePos = tr.mapping.slice(stepsBefore).map(blockInfo.content.beforePos);
- } else {
- // Fallback: scan within the mapped block range to find the inner table node
- const start = tr.mapping.slice(stepsBefore).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") {
- tablePos = pos;
- return false;
- }
- return true;
- });
- }
-
- const table = tablePos >= 0 ? tr.doc.nodeAt(tablePos) : null;
+ const table = tr.doc.nodeAt(tablePos);
if (!table || table.type.name !== "table") {
return false;
}
diff --git a/packages/core/src/api/blockManipulation/containers/containers.browser.test.ts b/packages/core/src/api/blockManipulation/containers/containers.browser.test.ts
new file mode 100644
index 0000000000..9d96276b73
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/containers.browser.test.ts
@@ -0,0 +1,264 @@
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+} from "vite-plus/test";
+import { userEvent } from "vite-plus/test/browser";
+
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+import { containerSchema } from "./containers.fixture.js";
+
+// Keymap tests for container blocks, split off from the node-environment
+// `containers.test.ts`. tiptap can only reach `handleKeyDown` through a
+// mounted view, so the editor is mounted and focused here and the keys are
+// pressed for real.
+
+const schema = containerSchema;
+
+let editor: BlockNoteEditor<
+ typeof schema.blockSchema,
+ typeof schema.inlineContentSchema,
+ typeof schema.styleSchema
+>;
+let div: HTMLElement;
+
+beforeAll(() => {
+ div = document.createElement("div");
+ document.body.appendChild(div);
+ editor = BlockNoteEditor.create({ schema });
+ editor.mount(div);
+});
+
+afterAll(() => {
+ editor._tiptapEditor.destroy();
+ div.remove();
+ editor = undefined as any;
+});
+
+beforeEach(() => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Paragraph 0" },
+ { id: "p-1", type: "paragraph", content: "Paragraph 1" },
+ ]);
+});
+
+/** Puts the caret at the given position and presses the key. */
+async function pressKey(
+ key: string,
+ at: { block: string; placement: "start" | "end" },
+) {
+ editor.setTextCursorPosition(at.block, at.placement);
+ editor.focus();
+ await userEvent.keyboard(`{${key}}`);
+}
+
+describe("children keyboard handling", () => {
+ it("Enter on an empty last child escapes the container", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "Hello" },
+ { id: "c-p-1", type: "paragraph", content: "" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ await pressKey("Enter", { block: "c-p-1", placement: "end" });
+
+ const callout = editor.getBlock("c-0")!;
+ expect(callout.children.map((child) => child.id)).toEqual(["c-p-0"]);
+ expect(editor.document.map((block) => block.type)).toEqual([
+ "callout",
+ "paragraph",
+ "paragraph",
+ ]);
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "c-0",
+ "c-p-1",
+ "trailing",
+ ]);
+ // The caret moves out with the block.
+ expect(editor.getTextCursorPosition().block.id).toBe("c-p-1");
+ });
+
+ it("Enter escape ascends past levels that can't hold the block", async () => {
+ // A grid holds only cells, so a block escaping the last cell can't stop
+ // at the grid level. It lands below the grid itself.
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "grid",
+ id: "g-0",
+ children: [
+ {
+ type: "gridCell",
+ id: "g-c-0",
+ children: [{ id: "g-p-0", type: "paragraph", content: "A" }],
+ },
+ {
+ type: "gridCell",
+ id: "g-c-1",
+ children: [
+ { id: "g-p-1", type: "paragraph", content: "B" },
+ { id: "g-p-2", type: "paragraph", content: "" },
+ ],
+ },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ await pressKey("Enter", { block: "g-p-2", placement: "end" });
+
+ expect(editor.getBlock("g-c-1")!.children.map((child) => child.id)).toEqual(
+ ["g-p-1"],
+ );
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "g-0",
+ "g-p-2",
+ "trailing",
+ ]);
+ expect(editor.getTextCursorPosition().block.id).toBe("g-p-2");
+ });
+
+ it("Enter on an empty block mid-container stays inside", async () => {
+ // The escape only fires at the end of the container. An empty block with
+ // siblings after it never ejects.
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "Hello" },
+ { id: "c-p-1", type: "paragraph", content: "" },
+ { id: "c-p-2", type: "paragraph", content: "World" },
+ ],
+ },
+ ]);
+
+ await pressKey("Enter", { block: "c-p-1", placement: "end" });
+
+ expect(editor.document.map((block) => block.id)).toEqual(["c-0"]);
+ expect(editor.getBlock("c-0")!.children).toHaveLength(4);
+ });
+
+ it("Backspace at the start of a container's first child moves it out", async () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "First" },
+ { id: "c-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+
+ await pressKey("Backspace", { block: "c-p-0", placement: "start" });
+
+ expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([
+ "c-p-1",
+ ]);
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "before",
+ "c-p-0",
+ "c-0",
+ ]);
+ expect(editor.getBlock("c-p-0")!.content).toEqual([
+ { type: "text", text: "First", styles: {} },
+ ]);
+ });
+
+ it("Backspace at the start of a block after a container moves it inside", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+
+ await pressKey("Backspace", { block: "after", placement: "start" });
+
+ expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([
+ "c-p-0",
+ "after",
+ ]);
+ expect(editor.document.map((block) => block.id)).toEqual(["c-0"]);
+ expect(editor.getBlock("after")!.content).toEqual([
+ { type: "text", text: "After", styles: {} },
+ ]);
+ });
+
+ it("Delete at the end of a block before a container pulls its first child out", async () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "First" },
+ { id: "c-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+
+ await pressKey("Delete", { block: "before", placement: "end" });
+
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "before",
+ "c-p-0",
+ "c-0",
+ ]);
+ expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([
+ "c-p-1",
+ ]);
+ });
+
+ it("Delete at the end of a container's last child pulls the next block in", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+
+ await pressKey("Delete", { block: "c-p-0", placement: "end" });
+
+ expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([
+ "c-p-0",
+ "after",
+ ]);
+ expect(editor.document.map((block) => block.id)).toEqual(["c-0"]);
+ });
+});
+
+// HTML round-trips (full, external, clipboard) live with the parse rules in
+// `schema/blocks/containerParse.browser.test.ts`.
+describe("children conversion", () => {
+ it("flattens containers to their children in markdown export", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "In callout" },
+ { id: "c-p-1", type: "heading", content: "Heading in callout" },
+ ],
+ },
+ ]);
+
+ const markdown = editor.blocksToMarkdownLossy(editor.document);
+ expect(markdown).toContain("In callout");
+ expect(markdown).toContain("# Heading in callout");
+ });
+});
diff --git a/packages/core/src/api/blockManipulation/containers/containers.fixture.ts b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts
new file mode 100644
index 0000000000..a32488ad5b
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts
@@ -0,0 +1,96 @@
+import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js";
+import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js";
+import { createBlockSpec } from "../../../schema/blocks/createSpec.js";
+
+const renderDiv = () => {
+ const dom = document.createElement("div");
+ return { dom, contentDOM: dom };
+};
+
+const Callout = createBlockSpec(
+ {
+ type: "callout" as const,
+ propSchema: {
+ flavor: {
+ default: "tip",
+ values: ["tip", "info", "warning", "success"],
+ },
+ },
+ content: "none",
+ children: { allow: "blocks" },
+ },
+ { render: renderDiv },
+)();
+
+const Grid = createBlockSpec(
+ {
+ type: "grid" as const,
+ propSchema: {},
+ content: "none",
+ children: {
+ allow: ["gridCell"],
+ min: 2,
+ },
+ },
+ { render: renderDiv },
+)();
+
+const GridCell = createBlockSpec(
+ {
+ type: "gridCell" as const,
+ propSchema: {},
+ content: "none",
+ children: { allow: "blocks" },
+ placeable: "namedOnly",
+ },
+ { render: renderDiv },
+)();
+
+// A container that requires two children, so dropping below `min` dissolves
+// it into the survivors.
+const Pair = createBlockSpec(
+ {
+ type: "pair" as const,
+ propSchema: {},
+ content: "none",
+ children: {
+ allow: "blocks",
+ min: 2,
+ },
+ },
+ { render: renderDiv },
+)();
+
+// A titled block: an ordinary block with inline content (the title) whose
+// `children` are a body that belongs to it. The frame draws the box around
+// title and body together.
+const Alert = createBlockSpec(
+ {
+ type: "alert" as const,
+ propSchema: {},
+ content: "inline",
+ children: { allow: "blocks" },
+ },
+ {
+ render: renderDiv,
+ renderFrame: () => {
+ const dom = document.createElement("div");
+ dom.className = "alert-frame";
+ const slot = document.createElement("div");
+ slot.className = "alert-slot";
+ dom.append(slot);
+ return { dom, slot };
+ },
+ },
+)();
+
+export const containerSchema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ callout: Callout,
+ alert: Alert,
+ grid: Grid,
+ gridCell: GridCell,
+ pair: Pair,
+ } as const,
+});
diff --git a/packages/core/src/api/blockManipulation/containers/containers.test.ts b/packages/core/src/api/blockManipulation/containers/containers.test.ts
new file mode 100644
index 0000000000..db5606501c
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/containers.test.ts
@@ -0,0 +1,598 @@
+// @vitest-environment node
+import { TextSelection } from "prosemirror-state";
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+} from "vite-plus/test";
+
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+import { createBlockSpec } from "../../../schema/blocks/createSpec.js";
+import { containerSchema } from "./containers.fixture.js";
+
+type PartialBlock = (typeof containerSchema)["PartialBlock"];
+
+// Document-model behaviour of container blocks: filling, schema enforcement,
+// repair and selection. Everything is `Block` JSON in and out, so the editor
+// runs headless with no DOM.
+//
+// The keymap (tiptap can only reach it through a mounted view) and
+// HTML/markdown serialization (builds real DOM) are tested in
+// `containers.browser.test.ts`.
+
+const schema = containerSchema;
+
+let editor: BlockNoteEditor<
+ typeof schema.blockSchema,
+ typeof schema.inlineContentSchema,
+ typeof schema.styleSchema
+>;
+
+beforeAll(() => {
+ editor = BlockNoteEditor.create({ schema });
+});
+
+afterAll(() => {
+ editor._tiptapEditor.destroy();
+ editor = undefined as any;
+});
+
+beforeEach(() => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Paragraph 0" },
+ { id: "p-1", type: "paragraph", content: "Paragraph 1" },
+ ]);
+});
+
+describe("children insertion & filling", () => {
+ it.each([
+ { block: { type: "callout" }, childType: "paragraph", min: 1 },
+ {
+ block: { type: "callout", children: [] },
+ childType: "paragraph",
+ min: 1,
+ },
+ { block: { type: "pair" }, childType: "paragraph", min: 2 },
+ { block: { type: "grid" }, childType: "gridCell", min: 2 },
+ ] satisfies { block: PartialBlock; childType: string; min: number }[])(
+ "fills $block to its minimum with identifiable $childType children",
+ ({ block, childType, min }) => {
+ editor.insertBlocks([{ ...block, id: "container" }], "p-1", "after");
+ const children = editor.getBlock("container")!.children;
+ expect(children.map((child) => child.type)).toEqual(
+ Array(min).fill(childType),
+ );
+ for (const child of children) {
+ expect(child.id).toBeTruthy();
+ expect(editor.getBlock(child.id)).toBeDefined();
+ }
+ },
+ );
+
+ it("does not re-fill a container round-tripped through the document", () => {
+ editor.insertBlocks([{ type: "callout", id: "c-0" }], "p-1", "after");
+ const inserted = editor.getBlock("c-0")!;
+
+ // `nodeToBlock` always emits an array, so a round-trip must not read an
+ // empty one as "unspecified" and fill on top of it.
+ editor.replaceBlocks([inserted], [inserted]);
+
+ expect(editor.getBlock("c-0")!.children).toHaveLength(
+ inserted.children.length,
+ );
+ });
+
+ it("accepts arbitrary block children, including nested containers", () => {
+ editor.insertBlocks(
+ [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { type: "heading", content: "In callout" },
+ {
+ type: "callout",
+ id: "c-1",
+ children: [{ type: "paragraph", content: "Nested" }],
+ },
+ ],
+ },
+ ],
+ "p-1",
+ "after",
+ );
+
+ const callout = editor.getBlock("c-0")!;
+ expect(callout.children.map((child) => child.type)).toEqual([
+ "heading",
+ "callout",
+ ]);
+ expect(editor.getBlock("c-1")!.children[0].type).toBe("paragraph");
+ });
+
+ it("enforces a restricted container's allow list", () => {
+ editor.insertBlocks(
+ [
+ {
+ type: "grid",
+ id: "g-0",
+ children: [{ type: "gridCell" }, { type: "gridCell" }],
+ },
+ ],
+ "p-1",
+ "after",
+ );
+ expect(editor.getBlock("g-0")!.children.map((child) => child.type)).toEqual(
+ ["gridCell", "gridCell"],
+ );
+
+ expect(() =>
+ editor.insertBlocks(
+ [
+ {
+ type: "grid",
+ children: [
+ { type: "paragraph", content: "not a cell" },
+ { type: "paragraph", content: "not a cell" },
+ ],
+ },
+ ],
+ "p-1",
+ "after",
+ ),
+ ).toThrow();
+ });
+
+ // The `allow: "blocks"` wildcard compiles to the regular blocks plus the
+ // containers placeable anywhere, so a namedOnly block only fits where a
+ // parent names it explicitly: not at the root, and not under a wildcard
+ // container.
+ it("rejects a namedOnly block outside a parent that names it", () => {
+ expect(() =>
+ editor.insertBlocks(
+ [{ type: "gridCell", children: [{ type: "paragraph" }] }],
+ "p-1",
+ "after",
+ ),
+ ).toThrow();
+
+ expect(() =>
+ editor.insertBlocks(
+ [
+ {
+ type: "callout",
+ children: [{ type: "gridCell", children: [{ type: "paragraph" }] }],
+ },
+ ],
+ "p-1",
+ "after",
+ ),
+ ).toThrow();
+ });
+});
+
+describe("container nodes", () => {
+ // No container is `isolating`. PM only honours that flag while no selection
+ // spans the edge, and nothing prevents one: given a spanning slice, `Fitter`
+ // refuses to open into the container and wraps the content in a spurious
+ // `blockGroup`, corrupting the document.
+ it("leaves every container non-isolating", () => {
+ const nodes = editor.pmSchema.nodes;
+ for (const type of ["callout", "pair", "grid", "gridCell"]) {
+ expect(nodes[type].spec.isolating).toBeFalsy();
+ }
+ });
+
+ // The corruption the line above avoids, pinned end to end: copy a selection
+ // running from inside a container to after it, paste it back over itself,
+ // and the document must come back unchanged. Marking the container
+ // `isolating` instead re-nests the whole fragment a level too deep.
+ it.each(["callout", "pair"])(
+ "round-trips a paste across a %s's edge",
+ (type) => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "c",
+ type,
+ children: [
+ { id: "c1", type: "paragraph", content: "Inner one" },
+ { id: "c2", type: "paragraph", content: "Inner two" },
+ ],
+ },
+ { id: "a", type: "paragraph", content: "After" },
+ ] as PartialBlock[]);
+
+ const before = JSON.stringify(editor.document);
+
+ editor.transact((tr) => {
+ let from = 0;
+ let to = 0;
+ tr.doc.descendants((node, pos) => {
+ if (node.isText && node.text === "Inner two") {
+ from = pos;
+ }
+ if (node.isText && node.text === "After") {
+ to = pos + node.nodeSize;
+ }
+ });
+
+ const selection = TextSelection.create(tr.doc, from, to);
+ tr.setSelection(selection).replace(from, to, selection.content());
+ });
+
+ expect(JSON.stringify(editor.document)).toBe(before);
+ },
+ );
+});
+
+// `initialContent` is the only path that builds a document without validating
+// it, since `blockToNode` is deliberately lenient and `createDocument` builds
+// from JSON. Regression: blocks that `insertBlocks` rejects loaded without
+// error, and a container below its `min` stayed there for the life of the
+// document.
+describe("initialContent enforcement", () => {
+ const createWith = (initialContent: PartialBlock[]) => {
+ return BlockNoteEditor.create({ schema, initialContent });
+ };
+
+ it("fills an explicitly empty `children` array up to `min`", () => {
+ const loaded = createWith([{ type: "callout", id: "c-0", children: [] }]);
+
+ const callout = loaded.getBlock("c-0")!;
+ expect(callout.children).toHaveLength(1);
+ expect(callout.children[0].type).toBe("paragraph");
+
+ loaded._tiptapEditor.destroy();
+ });
+
+ it("fills a container below `min` and rejects one it can never fill", () => {
+ // One cell under `min: 2` is padded up to it.
+ const loaded = createWith([
+ { type: "grid", id: "g-0", children: [{ type: "gridCell" }] },
+ ] as any);
+ expect(loaded.getBlock("g-0")!.children).toHaveLength(2);
+ loaded._tiptapEditor.destroy();
+
+ // A grid given paragraphs can never be filled: no amount of padding
+ // turns them into cells.
+ expect(() =>
+ createWith([
+ {
+ type: "grid",
+ id: "g-0",
+ children: [{ type: "paragraph" }, { type: "paragraph" }],
+ },
+ ] as any),
+ ).toThrow();
+ });
+});
+
+describe("children repair", () => {
+ it("dissolves a container that can stand anywhere when it drops below `min`", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [{ id: "c-p-0", type: "paragraph", content: "Only child" }],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["c-p-0"]);
+
+ // A callout the user emptied gets out of the way: it is replaced by what
+ // its children held, which is nothing.
+ expect(editor.getBlock("c-0")).toBeUndefined();
+ expect(editor.document.map((block) => block.id)).toEqual(["trailing"]);
+ });
+
+ it("unwraps a container whose single survivor cannot stand alone", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "grid",
+ id: "g-0",
+ children: [
+ {
+ type: "gridCell",
+ id: "cell-a",
+ children: [{ id: "cell-a-p", type: "paragraph", content: "A" }],
+ },
+ {
+ type: "gridCell",
+ id: "cell-b",
+ children: [{ id: "cell-b-p", type: "paragraph", content: "B" }],
+ },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["cell-a-p"]);
+
+ expect(editor.getBlock("g-0")).toBeUndefined();
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "cell-b-p",
+ "trailing",
+ ]);
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ // An emptied child of the container is dropped even when the container
+ // stays at or above `min`: an emptied column disappears rather than
+ // lingering. The multicolumn e2e snapshots pin the same behavior from the
+ // keyboard side.
+ it("drops emptied container children even at or above `min`", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "grid",
+ id: "g-0",
+ children: [
+ {
+ type: "gridCell",
+ id: "cell-a",
+ children: [
+ { id: "cell-a-p", type: "paragraph", content: "A" },
+ { id: "cell-a-extra", type: "paragraph", content: "A2" },
+ ],
+ },
+ {
+ type: "gridCell",
+ id: "cell-b",
+ children: [{ id: "cell-b-p", type: "paragraph", content: "B" }],
+ },
+ {
+ type: "gridCell",
+ id: "cell-c",
+ children: [{ id: "cell-c-p", type: "paragraph", content: "" }],
+ },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ // Removing a block inside cell A runs repair on the grid; the emptied
+ // cell C is dropped, and with cells A and B still meeting `min: 2` the
+ // grid itself survives.
+ editor.removeBlocks(["cell-a-extra"]);
+
+ const grid = editor.getBlock("g-0")!;
+ expect(grid.children.map((cell) => cell.id)).toEqual(["cell-a", "cell-b"]);
+ });
+});
+
+describe("repair edge cases", () => {
+ // A container that explicitly allows being empty. `min: 0` compiles to a
+ // `*` content expression, so ProseMirror never pads it and repair leaves
+ // it alone.
+ const Tray = createBlockSpec(
+ {
+ type: "tray" as const,
+ propSchema: {},
+ content: "none" as const,
+ children: { allow: "blocks", min: 0 },
+ },
+ {
+ render: () => {
+ const dom = document.createElement("div");
+ return { dom, contentDOM: dom };
+ },
+ },
+ )();
+
+ it("keeps a `min: 0` container with zero children", () => {
+ const trayEditor = BlockNoteEditor.create({
+ schema: containerSchema.extend({
+ blockSpecs: { tray: Tray },
+ }),
+ });
+ try {
+ trayEditor.replaceBlocks(trayEditor.document, [
+ {
+ type: "tray",
+ id: "t-0",
+ children: [{ id: "t-p-0", type: "paragraph", content: "" }],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ // Removing its only (empty) child leaves zero children, which `min: 0`
+ // allows: the tray stays instead of dissolving, and nothing is padded
+ // back.
+ trayEditor.removeBlocks(["t-p-0"]);
+
+ expect(trayEditor.getBlock("t-0")).toBeDefined();
+ expect(trayEditor.getBlock("t-0")!.children).toHaveLength(0);
+ expect(() => trayEditor.prosemirrorState.doc.check()).not.toThrow();
+ } finally {
+ trayEditor._tiptapEditor.destroy();
+ }
+ });
+
+ it("keeps emptied regular blocks when the container survives", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-a", type: "paragraph", content: "A" },
+ { id: "c-b", type: "paragraph", content: "B" },
+ { id: "c-empty", type: "paragraph", content: "" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["c-a"]);
+
+ // Only emptied *container* children are dropped. The empty paragraph is
+ // content the user typed into, not structure, so it stays.
+ expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([
+ "c-b",
+ "c-empty",
+ ]);
+ });
+
+ it("dissolves a `min: 2` container left with one surviving child", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "pair",
+ id: "s-0",
+ children: [
+ { id: "s-a", type: "paragraph", content: "A" },
+ { id: "s-b", type: "paragraph", content: "B" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["s-b"]);
+
+ // One survivor is below `min: 2`, so the pair dissolves into it. The
+ // padded empty ProseMirror filled back does not count as a survivor.
+ expect(editor.getBlock("s-0")).toBeUndefined();
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "s-a",
+ "trailing",
+ ]);
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("leaves a titled block intact when its last body block is removed", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "alert",
+ id: "w",
+ content: "Title",
+ children: [{ id: "b1", type: "paragraph", content: "" }],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["b1"]);
+
+ // Titled blocks never enter `fixContainer`: the alert keeps its title
+ // with an empty body rather than dissolving.
+ const alert = editor.getBlock("w")! as any;
+ expect(alert.type).toBe("alert");
+ expect(alert.content[0].text).toBe("Title");
+ expect(alert.children).toHaveLength(0);
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+});
+
+describe("children selection", () => {
+ it("getSelectionCutBlocks handles selections reaching into a container", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "First" },
+ { id: "c-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+ editor.setSelection("before", "c-p-0");
+
+ // Previously threw "unexpected" for any partial selection touching a
+ // container (breaking comments/AI selection handling).
+ const result = editor.getSelectionCutBlocks();
+ expect(result.blocks.length).toBeGreaterThanOrEqual(1);
+ expect(result.blocks.map((block) => block.id)).toContain("before");
+ });
+});
+
+// Every mutation that can empty a container records its ancestors with
+// `getAncestorContainers` and hands them to `fixContainersById`, which repairs
+// them deepest-first. Removing a block therefore repairs the whole chain it
+// sat in, not just the container directly holding it.
+describe("ancestor container repair", () => {
+ it("repairs every container a single removal emptied", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [{ id: "c-p-0", type: "paragraph", content: "Only child" }],
+ },
+ {
+ type: "pair",
+ id: "s-0",
+ children: [
+ { id: "s-p-0", type: "paragraph", content: "Kept" },
+ { id: "s-p-1", type: "paragraph", content: "Removed" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ // Blocks from two different containers in one call: each container is
+ // recorded once and repaired on its own terms.
+ editor.removeBlocks(["c-p-0", "s-p-1"]);
+
+ // Both containers dissolve: the callout held nothing else, and the pair
+ // is replaced by the one child that still carried content.
+ expect(editor.getBlock("c-0")).toBeUndefined();
+ expect(editor.getBlock("s-0")).toBeUndefined();
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "s-p-0",
+ "trailing",
+ ]);
+ });
+
+ it("cascades a repair outwards from the deepest container", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "grid",
+ id: "g-outer",
+ children: [
+ {
+ type: "gridCell",
+ id: "outer-cell-a",
+ children: [
+ {
+ type: "grid",
+ id: "g-inner",
+ children: [
+ {
+ type: "gridCell",
+ id: "inner-cell-a",
+ children: [
+ { id: "inner-p", type: "paragraph", content: "X" },
+ ],
+ },
+ {
+ type: "gridCell",
+ id: "inner-cell-b",
+ children: [{ type: "paragraph", content: "" }],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "gridCell",
+ id: "outer-cell-b",
+ children: [{ type: "paragraph", content: "" }],
+ },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ // The only real content, three containers deep. Removing it empties the
+ // inner cell, and the emptiness has to travel all the way out: the inner
+ // grid loses both its cells, the outer cell loses the inner grid, and the
+ // outer grid loses both of its cells. Recording only the innermost
+ // container would leave a stack of empty grids behind.
+ editor.removeBlocks(["inner-p"]);
+
+ expect(editor.getBlock("g-inner")).toBeUndefined();
+ expect(editor.getBlock("g-outer")).toBeUndefined();
+ expect(editor.document.map((block) => block.id)).toEqual(["trailing"]);
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+});
diff --git a/packages/core/src/api/blockManipulation/containers/fixContainer.ts b/packages/core/src/api/blockManipulation/containers/fixContainer.ts
new file mode 100644
index 0000000000..ea467db1e1
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts
@@ -0,0 +1,131 @@
+import { Fragment, type Node } from "prosemirror-model";
+import { type Transaction } from "prosemirror-state";
+
+import {
+ isContainerNode,
+ isNamedOnly,
+} from "../../../schema/blocks/children.js";
+import { getNodeById } from "../../nodeUtil.js";
+
+/**
+ * Whether `node` is a container child the user has emptied out: a container
+ * (a column, a cell) holding nothing but one empty paragraph, or one holding
+ * nothing at all that its content expression requires children.
+ * @internal
+ */
+export function isEmptyContainerChild(node: Node): boolean {
+ if (node.type.name === "blockContainer") {
+ const content = node.firstChild;
+ return (
+ node.childCount === 1 &&
+ !!content &&
+ content.type.name === "paragraph" &&
+ content.childCount === 0
+ );
+ }
+ if (isContainerNode(node.type)) {
+ if (node.childCount === 1) {
+ return isEmptyContainerChild(node.firstChild!);
+ }
+ // A container left with no children at all — its last child was removed
+ // and no fill happened on the way. A `min: 0` container in this state is
+ // valid and stays; any other is broken structure.
+ if (node.childCount === 0) {
+ const children = node.type.spec.blockConfig?.children;
+ return !!children && (children.min ?? 1) >= 1;
+ }
+ return false;
+ }
+ return false;
+}
+
+/**
+ * Repairs the container at `containerPos` after children were (re)moved from
+ * it: drops the ones the user emptied, and dissolves the container when too
+ * few are left for it to mean anything (a column list with one column is just
+ * that column's blocks).
+ *
+ * A container that only exists inside another container (a column) is left to
+ * its parent, which is the thing that decides whether it still belongs.
+ *
+ * @param containerPos The position just before the container node.
+ * @internal
+ */
+export function fixContainer(tr: Transaction, containerPos: number) {
+ const container = tr.doc.resolve(containerPos).nodeAfter;
+ if (!container || !isContainerNode(container.type)) {
+ throw new Error(
+ "Invalid containerPos: does not point to a container node.",
+ );
+ }
+
+ // A `namedOnly` container (a column) keeps existing: it is its parent's
+ // decision whether the container still belongs, and ProseMirror pads it
+ // back up to its minimum when children are removed.
+ if (isNamedOnly(container.type)) {
+ return;
+ }
+
+ const childrenConfig = container.type.spec.blockConfig?.children;
+ const min = childrenConfig ? (childrenConfig.min ?? 1) : 1;
+ const survivors: Node[] = [];
+ const emptied: { from: number; to: number }[] = [];
+ container.forEach((child, offset) => {
+ if (!isEmptyContainerChild(child)) {
+ survivors.push(child);
+ } else if (isContainerNode(child.type)) {
+ const from = containerPos + 1 + offset;
+ emptied.push({ from, to: from + child.nodeSize });
+ }
+ });
+
+ if (survivors.length >= min) {
+ // Keep intentional empty paragraphs. Only remove empty structural children,
+ // back to front so positions (and selections in surviving children) stay valid.
+ for (const { from, to } of emptied.reverse()) {
+ tr.delete(from, to);
+ }
+ return;
+ }
+
+ // Too few children left for the container to mean anything, so it is
+ // replaced by its surviving children.
+ const replacement: Node[] = [];
+ for (const survivor of survivors) {
+ if (isNamedOnly(survivor.type)) {
+ // The survivor can't stand on its own either (a column only exists
+ // inside a column list), so what it holds is what's left.
+ survivor.forEach((grandChild) => replacement.push(grandChild));
+ } else {
+ replacement.push(survivor);
+ }
+ }
+
+ tr.replaceWith(
+ containerPos,
+ containerPos + container.nodeSize,
+ Fragment.from(replacement),
+ );
+}
+
+/**
+ * Runs {@link fixContainer} on each of the given containers, looked up by ID
+ * in `tr`'s current doc. Containers are repaired deepest-first so that an
+ * inner repair (e.g. a column emptying out) is observed by the outer
+ * container's repair (e.g. its columnList unwrapping) in the same pass.
+ * Containers that no longer exist by the time their turn comes are skipped —
+ * an earlier repair may have removed them.
+ */
+export function fixContainersById(
+ tr: Transaction,
+ containers: { id: string; depth: number }[],
+) {
+ [...containers]
+ .sort((a, b) => b.depth - a.depth)
+ .forEach(({ id }) => {
+ const target = getNodeById(id, tr.doc);
+ if (target && isContainerNode(target.node.type)) {
+ fixContainer(tr, target.posBeforeNode);
+ }
+ });
+}
diff --git a/packages/core/src/api/blockManipulation/containers/plainBlocks.test.ts b/packages/core/src/api/blockManipulation/containers/plainBlocks.test.ts
new file mode 100644
index 0000000000..7fd3b4dc58
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/plainBlocks.test.ts
@@ -0,0 +1,197 @@
+import { TextSelection } from "prosemirror-state";
+import { afterEach, describe, expect, it } from "vite-plus/test";
+
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+import { createBlockSpec } from "../../../schema/blocks/createSpec.js";
+import { containerSchema } from "./containers.fixture.js";
+
+const plainNote = createBlockSpec(
+ {
+ type: "plainNote",
+ propSchema: {},
+ content: "plain",
+ children: { allow: "blocks" },
+ },
+ {
+ render() {
+ const dom = document.createElement("pre");
+ return { dom, contentDOM: dom };
+ },
+ renderFrame() {
+ const dom = document.createElement("section");
+ dom.className = "plain-frame";
+ const slot = document.createElement("div");
+ dom.append(slot);
+ return { dom, slot };
+ },
+ },
+)();
+const schema = containerSchema.extend({ blockSpecs: { plainNote } });
+const editors = new Set>();
+
+function editorWith(
+ content = "Source",
+ children: (typeof schema.PartialBlock)[] = [
+ { id: "body", type: "paragraph", content: "Explanation" },
+ ],
+) {
+ const editor = BlockNoteEditor.create({
+ schema,
+ initialContent: [
+ { id: "note", type: "plainNote", content, children },
+ { id: "after", type: "paragraph", content: "After" },
+ ],
+ });
+ editors.add(editor);
+ editor.mount(document.createElement("div"));
+ return editor;
+}
+
+afterEach(() => {
+ for (const editor of editors) {
+ editor._tiptapEditor.destroy();
+ }
+ editors.clear();
+});
+
+function press(
+ editor: ReturnType,
+ key: "Enter" | "Backspace" | "Delete" | "Tab",
+ shiftKey = false,
+) {
+ const view = editor.prosemirrorView;
+ return view.someProp("handleKeyDown", (handler) =>
+ handler(
+ view,
+ new KeyboardEvent("keydown", {
+ key,
+ code: key,
+ keyCode: { Enter: 13, Backspace: 8, Delete: 46, Tab: 9 }[key],
+ shiftKey,
+ }),
+ ),
+ );
+}
+function text(editor: ReturnType, id: string) {
+ const block = editor.getBlock(id)!;
+ if (block.type !== "plainNote" && block.type !== "paragraph") {
+ throw new Error("Expected a text block");
+ }
+ return block.content
+ .map((part) => (part.type === "text" ? part.text : ""))
+ .join("");
+}
+
+describe("plain blocks with owned children", () => {
+ it.each(["", "Source"])(
+ "Enter starts the body and preserves children for %j",
+ (content) => {
+ const editor = editorWith(content);
+ editor.setTextCursorPosition("note", "end");
+ press(editor, "Enter");
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "note",
+ "after",
+ ]);
+ expect(
+ editor.getBlock("note")!.children.map((block) => block.id),
+ ).toEqual([expect.any(String), "body"]);
+ expect(text(editor, "body")).toBe("Explanation");
+ expect(editor.getTextCursorPosition().block.id).toBe(
+ editor.getBlock("note")!.children[0].id,
+ );
+ editor.prosemirrorState.doc.check();
+ },
+ );
+
+ it("Enter moves the remaining plain text into the body", () => {
+ const editor = editorWith("Source");
+ editor.setTextCursorPosition("note", "start");
+ editor.transact((tr) =>
+ tr.setSelection(TextSelection.create(tr.doc, tr.selection.from + 3)),
+ );
+ press(editor, "Enter");
+ expect(text(editor, "note")).toBe("Sou");
+ expect(text(editor, editor.getBlock("note")!.children[0].id)).toBe("rce");
+ expect(text(editor, "body")).toBe("Explanation");
+ });
+
+ it("Shift-Enter inserts a newline without moving children", () => {
+ const editor = editorWith();
+ editor.setTextCursorPosition("note", "end");
+ press(editor, "Enter", true);
+ expect(text(editor, "note")).toBe("Source\n");
+ expect(editor.getBlock("note")!.children.map((block) => block.id)).toEqual([
+ "body",
+ ]);
+ editor.prosemirrorState.doc.check();
+ });
+
+ it.each(["Backspace", "Delete"] as const)(
+ "%s preserves child text when merging into plain content",
+ (key) => {
+ const editor = editorWith("Source", [
+ {
+ id: "body",
+ type: "paragraph",
+ content: [
+ { type: "text", text: "Bold", styles: { bold: true } },
+ "\nNext",
+ ],
+ children: [{ id: "nested", type: "paragraph", content: "Nested" }],
+ },
+ ]);
+ editor.setTextCursorPosition("note", "end");
+ const joinPosition = editor.prosemirrorState.selection.from;
+ editor.setTextCursorPosition(
+ key === "Backspace" ? "body" : "note",
+ key === "Backspace" ? "start" : "end",
+ );
+ press(editor, key);
+ expect(editor.prosemirrorState.selection.from).toBe(joinPosition);
+ expect(text(editor, "note")).toBe("SourceBold\nNext");
+ expect(editor.getBlock("note")!.content).toEqual([
+ { type: "text", text: "SourceBold\nNext", styles: {} },
+ ]);
+ expect(
+ editor.getBlock("note")!.children.map((block) => block.id),
+ ).toEqual(["nested"]);
+ expect(editor.getBlock("body")).toBeUndefined();
+ editor.prosemirrorState.doc.check();
+ },
+ );
+
+ it("keeps owned children when Shift-Tab is pressed", () => {
+ const editor = editorWith();
+ editor.setTextCursorPosition("body", "start");
+ press(editor, "Tab", true);
+ expect(editor.getBlock("note")!.children.map((block) => block.id)).toEqual([
+ "body",
+ ]);
+ });
+
+ it("renders and round-trips multiline content with its children", () => {
+ const editor = editorWith("First\nSecond");
+ expect(editor.domElement?.querySelector(".plain-frame")?.textContent).toBe(
+ "First\nSecondExplanation",
+ );
+ const parsed = editor.tryParseHTMLToBlocks(
+ editor.blocksToFullHTML(editor.document),
+ );
+ expect(parsed).toEqual(editor.document);
+ expect(editor.blocksToHTMLLossy(editor.document)).toContain("Explanation");
+ });
+
+ it("preserves text and children when converting between plain, inline, and container blocks", () => {
+ const editor = editorWith("First\nSecond");
+ editor.updateBlock("note", { type: "alert" });
+ expect(editor.getBlock("note")!.children[0].id).toBe("body");
+ editor.updateBlock("note", { type: "plainNote" });
+ expect(text(editor, "note")).toBe("First\nSecond");
+ editor.updateBlock("note", { type: "callout" });
+ const container = editor.document[0];
+ expect(text(editor, container.children[0].id)).toBe("First\nSecond");
+ expect(container.children[1].id).toBe("body");
+ editor.prosemirrorState.doc.check();
+ });
+});
diff --git a/packages/core/src/api/blockManipulation/containers/titledBlocks.test.ts b/packages/core/src/api/blockManipulation/containers/titledBlocks.test.ts
new file mode 100644
index 0000000000..af683b567d
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/titledBlocks.test.ts
@@ -0,0 +1,352 @@
+import { NodeSelection, TextSelection } from "prosemirror-state";
+import { afterEach, describe, expect, it } from "vite-plus/test";
+
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+import {
+ hasOwnedChildren,
+ isContainerNode,
+} from "../../../schema/blocks/children.js";
+import { getBlockInfoAt } from "../../getBlockInfoFromPos.js";
+import { getNodeById } from "../../nodeUtil.js";
+import { containerSchema } from "./containers.fixture.js";
+
+// Behaviour of titled blocks: an ordinary block with inline content
+// (the title) whose `children` are a body that belongs to it. The `alert`
+// fixture block is the specimen; `callout` (a pure container) is the control.
+
+const schema = containerSchema;
+const editors = new Set>();
+
+afterEach(() => {
+ for (const editor of editors) {
+ editor._tiptapEditor.destroy();
+ }
+ editors.clear();
+});
+
+function editorWith(initialContent: any[]) {
+ const editor = BlockNoteEditor.create({ schema, initialContent } as any);
+ editors.add(editor);
+ editor.mount(document.createElement("div"));
+ return editor;
+}
+
+function press(editor: any, key: string, mods: string[] = []) {
+ const view = editor._tiptapEditor.view;
+ const codes: Record = {
+ Enter: 13,
+ Backspace: 8,
+ Tab: 9,
+ Delete: 46,
+ };
+ const event = new KeyboardEvent("keydown", {
+ key,
+ code: key,
+ keyCode: codes[key],
+ bubbles: true,
+ shiftKey: mods.includes("Shift"),
+ } as any);
+ return !!view.someProp("handleKeyDown", (f: any) => f(view, event));
+}
+
+function shape(blocks: any[]): string {
+ return blocks
+ .map((block) => {
+ const text = Array.isArray(block.content)
+ ? block.content.map((c: any) => c.text ?? "").join("")
+ : "";
+ const children = block.children?.length
+ ? `[${shape(block.children)}]`
+ : "";
+ return `${block.type}"${text}"${children}`;
+ })
+ .join(", ");
+}
+
+const before = { id: "pre", type: "paragraph" as const, content: "Before" };
+const after = { id: "post", type: "paragraph" as const, content: "After" };
+const body = [
+ { id: "b1", type: "paragraph" as const, content: "One" },
+ { id: "b2", type: "paragraph" as const, content: "Two" },
+];
+
+const withAlert = (children: any[] = body) => [
+ before,
+ { id: "w", type: "alert" as const, content: "Title", children },
+ after,
+];
+
+describe("titled-block schema shape", () => {
+ it("recognizes declared ownership on ordinary blocks", () => {
+ const editor = editorWith(withAlert());
+
+ editor.transact((tr) => {
+ const alert = getNodeById("w", tr.doc)!;
+ // An ordinary blockContainer: its node holds content, not children.
+ expect(alert.node.type.name).toBe("blockContainer");
+ expect(isContainerNode(alert.node.type)).toBe(false);
+ expect(hasOwnedChildren(alert.node)).toBe(true);
+ expect(alert.node.attrs.id).toBe("w");
+ expect(alert.node.firstChild!.attrs).not.toHaveProperty("id");
+
+ expect(hasOwnedChildren(getNodeById("pre", tr.doc)!.node)).toBe(false);
+
+ // The body is the blockGroup the alert nests, resolved with positions.
+ const info = getBlockInfoAt(tr.doc, alert.posBeforeNode);
+ expect(info.children?.node.type.name).toBe("blockGroup");
+ expect(info.children?.beforePos).toBe(info.content?.afterPos);
+ });
+ });
+
+ it("frames the title and the body together in the live DOM", () => {
+ const editor = editorWith(withAlert());
+
+ const frame = editor.domElement!.querySelector(".alert-frame");
+ expect(frame).not.toBeNull();
+ const slot = frame!.querySelector(".alert-slot");
+ expect(slot).not.toBeNull();
+ // The title's text and both body blocks render inside the slot.
+ expect(slot!.textContent).toContain("Title");
+ expect(slot!.textContent).toContain("One");
+ expect(slot!.textContent).toContain("Two");
+ });
+});
+
+describe("a titled block's keyboard behaviour", () => {
+ it.each([{ children: [] }, { children: body }])(
+ "Enter in an empty title preserves its body (%j)",
+ ({ children }) => {
+ const editor = editorWith([
+ before,
+ { id: "w", type: "alert", content: "", children },
+ after,
+ ]);
+ editor.setTextCursorPosition("w", "start");
+ expect(press(editor, "Enter")).toBe(true);
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "pre",
+ "w",
+ "post",
+ ]);
+ expect(shape(editor.document[1].children)).toBe(
+ [
+ 'paragraph""',
+ ...children.map((block) => `paragraph"${block.content}"`),
+ ].join(", "),
+ );
+ expect(
+ editor.document[1].children.slice(1).map((block) => block.id),
+ ).toEqual(children.map((block) => block.id));
+ expect(editor.getTextCursorPosition().block.id).toBe(
+ editor.document[1].children[0].id,
+ );
+ },
+ );
+
+ it("Enter at the end of the title starts the body, keeping it", () => {
+ const editor = editorWith(withAlert());
+ editor.setTextCursorPosition("w", "end");
+ press(editor, "Enter");
+
+ // The new block belongs to the alert, and the body is still the
+ // alert's — not carried off by a new sibling.
+ expect(shape(editor.document)).toBe(
+ 'paragraph"Before", alert"Title"[paragraph"", paragraph"One", paragraph"Two"], paragraph"After"',
+ );
+ });
+
+ it("Enter in the middle of the title keeps the body on the alert", () => {
+ // The bug behind the toggle-block reports: splitting a block handed its
+ // children to the new block, so a callout's body ended up under whatever
+ // the split created.
+ const editor = editorWith(withAlert());
+ editor.setTextCursorPosition("w", "start");
+ editor.transact((tr) =>
+ tr.setSelection(TextSelection.create(tr.doc, tr.selection.from + 2)),
+ );
+ press(editor, "Enter");
+
+ expect(shape(editor.document)).toBe(
+ 'paragraph"Before", alert"Ti"[paragraph"tle", paragraph"One", paragraph"Two"], paragraph"After"',
+ );
+ });
+
+ it("Enter in an empty last body block leaves the alert", () => {
+ const editor = editorWith(
+ withAlert([body[0], { id: "b2", type: "paragraph", content: "" }]),
+ );
+ editor.setTextCursorPosition("b2", "start");
+ press(editor, "Enter");
+
+ expect(shape(editor.document)).toBe(
+ 'paragraph"Before", alert"Title"[paragraph"One"], paragraph"", paragraph"After"',
+ );
+ });
+
+ it("Enter in an empty body block that is the only one stays put", () => {
+ // Nothing to escape from yet: the block is where a new alert's body
+ // starts, and leaving would dissolve the alert the user just made.
+ const editor = editorWith(
+ withAlert([{ id: "b1", type: "paragraph", content: "" }]),
+ );
+ editor.setTextCursorPosition("b1", "start");
+ press(editor, "Enter");
+
+ expect(shape(editor.document)).toBe(
+ 'paragraph"Before", alert"Title"[paragraph"", paragraph""], paragraph"After"',
+ );
+ });
+
+ it("Backspace at the start of the first body block merges into the title", () => {
+ const editor = editorWith(withAlert());
+ editor.setTextCursorPosition("b1", "start");
+ press(editor, "Backspace");
+
+ expect(shape(editor.document)).toBe(
+ 'paragraph"Before", alert"TitleOne"[paragraph"Two"], paragraph"After"',
+ );
+ });
+
+ it("Shift-Tab in the body does not escape the alert", () => {
+ const editor = editorWith(withAlert());
+ editor.setTextCursorPosition("b1", "start");
+ press(editor, "Tab", ["Shift"]);
+
+ expect(shape(editor.document)).toBe(
+ 'paragraph"Before", alert"Title"[paragraph"One", paragraph"Two"], paragraph"After"',
+ );
+ });
+
+ it("Backspace in the block after moves it into the body, whole", () => {
+ const editor = editorWith(withAlert());
+ editor.setTextCursorPosition("post", "start");
+ press(editor, "Backspace");
+
+ // Moved in as its own block: text never merges across the edge.
+ expect(shape(editor.document)).toBe(
+ 'paragraph"Before", alert"Title"[paragraph"One", paragraph"Two", paragraph"After"]',
+ );
+ });
+
+ it("Tab still nests inside the body", () => {
+ const editor = editorWith(withAlert());
+ editor.setTextCursorPosition("b2", "start");
+ press(editor, "Tab");
+
+ expect(shape(editor.document)).toBe(
+ 'paragraph"Before", alert"Title"[paragraph"One"[paragraph"Two"]], paragraph"After"',
+ );
+ });
+
+ it("Delete at the end of the title merges the first body block into it", () => {
+ // The mirror of Backspace at the start of the first body block: the
+ // body's first block is consumed and its text joins the title.
+ const editor = editorWith(withAlert());
+ editor.setTextCursorPosition("w", "end");
+ press(editor, "Delete");
+
+ expect(shape(editor.document)).toBe(
+ 'paragraph"Before", alert"TitleOne"[paragraph"Two"], paragraph"After"',
+ );
+ });
+
+ it("Enter with a non-collapsed selection in the title takes the generic split path", () => {
+ // The titled block's Enter handler only fires for a collapsed selection,
+ // so a range selection falls through to the generic split: the selected
+ // text is deleted and the title splits, keeping the body on the alert.
+ const editor = editorWith(withAlert());
+ editor.setTextCursorPosition("w", "start");
+ editor.transact((tr) =>
+ tr.setSelection(
+ TextSelection.create(
+ tr.doc,
+ tr.selection.from + 1,
+ tr.selection.from + 3,
+ ),
+ ),
+ );
+ press(editor, "Enter");
+
+ expect(shape(editor.document)).toBe(
+ 'paragraph"Before", alert"T"[paragraph"One", paragraph"Two"], paragraph"le", paragraph"After"',
+ );
+ });
+});
+
+describe("converting between a titled block and a pure container", () => {
+ it("carries the title into the body when an alert becomes a callout", () => {
+ const editor = editorWith(withAlert());
+ editor.updateBlock("w" as any, { type: "callout" } as any);
+
+ // A container holds no content of its own, so the title moves into the
+ // body as its first paragraph; the existing children stay after it, in
+ // order.
+ expect(shape(editor.document)).toBe(
+ 'paragraph"Before", callout""[paragraph"Title", paragraph"One", paragraph"Two"], paragraph"After"',
+ );
+ });
+
+ it("invents an empty title when a callout becomes an alert", () => {
+ const editor = editorWith([
+ before,
+ {
+ id: "w",
+ type: "callout" as const,
+ children: [
+ { id: "b1", type: "paragraph" as const, content: "One" },
+ { id: "b2", type: "paragraph" as const, content: "Two" },
+ ],
+ },
+ after,
+ ]);
+ editor.updateBlock("w" as any, { type: "alert" } as any);
+
+ // The container had no title to carry, so the alert starts empty; its
+ // children move across untouched.
+ expect(shape(editor.document)).toBe(
+ 'paragraph"Before", alert""[paragraph"One", paragraph"Two"], paragraph"After"',
+ );
+ });
+});
+
+describe("blocks that declare no children are untouched", () => {
+ it("keeps ordinary nesting behaviour for a nested paragraph", () => {
+ const editor = editorWith([
+ before,
+ { id: "w", type: "paragraph", content: "Title", children: body },
+ after,
+ ]);
+ editor.setTextCursorPosition("b1", "start");
+ press(editor, "Tab", ["Shift"]);
+
+ // Shift-Tab lifts it out, as it always has.
+ expect(shape(editor.document)).toBe(
+ 'paragraph"Before", paragraph"Title", paragraph"One"[paragraph"Two"], paragraph"After"',
+ );
+ });
+
+ it("survives the clipboard round-trip that copy and drag use", () => {
+ // Dragging a block inside the editor re-parses it from the HTML
+ // ProseMirror serializes the dragged slice to, so a titled block whose
+ // parse rules don't match that HTML comes back as a paragraph.
+ const editor = editorWith(withAlert());
+ const view = editor._tiptapEditor.view;
+
+ editor.transact((tr) => {
+ let pos = -1;
+ tr.doc.descendants((node: any, at: number) => {
+ if (pos < 0 && node.attrs?.id === "w") {
+ pos = at;
+ }
+ return pos < 0;
+ });
+ tr.setSelection(NodeSelection.create(tr.doc, pos));
+ });
+ const html = view.serializeForClipboard(view.state.selection.content()).dom
+ .innerHTML;
+
+ expect(shape(editor.tryParseHTMLToBlocks(html))).toBe(
+ 'alert"Title"[paragraph"One", paragraph"Two"]',
+ );
+ });
+});
diff --git a/packages/core/src/api/blockManipulation/selections/selection.ts b/packages/core/src/api/blockManipulation/selections/selection.ts
index 34591c8c8d..ff60114b79 100644
--- a/packages/core/src/api/blockManipulation/selections/selection.ts
+++ b/packages/core/src/api/blockManipulation/selections/selection.ts
@@ -7,6 +7,7 @@ import {
InlineContentSchema,
StyleSchema,
} from "../../../schema/index.js";
+import { CHILD_CONTAINER_GROUP } from "../../../schema/blocks/children.js";
import { expandPMRangeToWords } from "../../../util/expandToWords.js";
import {
blockEdgePos,
@@ -100,7 +101,7 @@ export function getSelection<
for (let depth = $startBlockBeforePos.depth; depth > sharedDepth; depth--) {
const parentNode = $startBlockBeforePos.node(depth);
- if (parentNode.type.isInGroup("childContainer")) {
+ if (parentNode.type.isInGroup(CHILD_CONTAINER_GROUP)) {
const startIndexAtDepth = $startBlockBeforePos.index(depth) + 1;
const childCountAtDepth = $startBlockBeforePos.node(depth).childCount;
diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
index e2274140f7..c381722789 100644
--- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
+++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
@@ -8,6 +8,8 @@ import {
InlineContentSchema,
StyleSchema,
} from "../../../../schema/index.js";
+import { isContainerNode } from "../../../../schema/blocks/children.js";
+import { containerRootDOM } from "../../../../schema/blocks/createSpec.js";
import { UnreachableCaseError } from "../../../../util/typescript.js";
import {
inlineContentToNodes,
@@ -224,10 +226,11 @@ function serializeBlock<
const blockImplementation = editor.blockImplementations[block.type as any]
.implementation as BlockImplementation;
+ const blockWithDefaults = { ...block, props, children: block.children ?? [] };
const ret =
blockImplementation.toExternalHTML?.call(
{},
- { ...block, props } as any,
+ blockWithDefaults as any,
editor as any,
{
nestingLevel,
@@ -235,16 +238,25 @@ function serializeBlock<
) ||
blockImplementation.render.call(
{},
- { ...block, props } as any,
+ blockWithDefaults as any,
editor as any,
);
const elementFragment = doc.createDocumentFragment();
- if ((ret.dom as HTMLElement).classList.contains("bn-block-content")) {
+ // React renders can return a fragment around the root element.
+ const rootElement = containerRootDOM(ret);
+
+ const blockContentRoot = rootElement?.classList.contains("bn-block-content")
+ ? rootElement
+ : ret.contentDOM?.closest(".bn-block-content");
+
+ elementFragment.append(ret.dom);
+
+ if (blockContentRoot) {
const blockContentDataAttributes = [
...attrs,
- ...Array.from((ret.dom as HTMLElement).attributes),
+ ...Array.from(blockContentRoot.attributes),
].filter(
(attr) =>
attr.name.startsWith("data") &&
@@ -256,26 +268,32 @@ function serializeBlock<
attr.name !== "data-editable",
);
- // ret.dom = ret.dom.firstChild! as any;
for (const attr of blockContentDataAttributes) {
- (ret.dom.firstChild! as HTMLElement).setAttribute(attr.name, attr.value);
+ (blockContentRoot.firstChild! as HTMLElement).setAttribute(
+ attr.name,
+ attr.value,
+ );
}
- addAttributesAndRemoveClasses(ret.dom.firstChild! as HTMLElement);
+ addAttributesAndRemoveClasses(blockContentRoot.firstChild! as HTMLElement);
if (nestingLevel > 0) {
- (ret.dom.firstChild! as HTMLElement).setAttribute(
+ (blockContentRoot.firstChild! as HTMLElement).setAttribute(
"data-nesting-level",
nestingLevel.toString(),
);
}
- elementFragment.append(...Array.from(ret.dom.childNodes));
+ // Unwrap the content in place, preserving any surrounding frame.
+ blockContentRoot.replaceWith(...Array.from(blockContentRoot.childNodes));
} else {
- elementFragment.append(ret.dom);
+ if (isContainerNode(editor.pmSchema.nodes[block.type as any])) {
+ // Pasted external HTML gets fresh IDs; scope parsing to actual children.
+ rootElement?.removeAttribute("data-id");
+ const childrenDOM =
+ ("childrenDOM" in ret && ret.childrenDOM) || ret.contentDOM;
+ childrenDOM?.setAttribute("data-children-of", block.type!);
+ }
if (nestingLevel > 0) {
- (ret.dom as HTMLElement).setAttribute(
- "data-nesting-level",
- nestingLevel.toString(),
- );
+ rootElement?.setAttribute("data-nesting-level", nestingLevel.toString());
}
}
@@ -301,11 +319,9 @@ function serializeBlock<
// tables) fill their `contentDOM` with child blocks later on, and code
// blocks would turn the placeholder into literal content.
const blockNodeType = editor.pmSchema.nodes[block.type as any];
- if (
- blockNodeType?.inlineContent &&
- !blockNodeType.spec.code &&
- ret.contentDOM.childNodes.length === 0
- ) {
+ const needsPlaceholder =
+ !!blockNodeType?.inlineContent && !blockNodeType.spec.code;
+ if (needsPlaceholder && ret.contentDOM.childNodes.length === 0) {
ret.contentDOM.appendChild(doc.createTextNode(EMPTY_BLOCK_PLACEHOLDER));
}
}
diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts
index 0f890b77ab..663a5c9689 100644
--- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts
+++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts
@@ -7,6 +7,7 @@ import {
InlineContentSchema,
StyleSchema,
} from "../../../../schema/index.js";
+import { isContainerNode } from "../../../../schema/blocks/children.js";
import { UnreachableCaseError } from "../../../../util/typescript.js";
import {
inlineContentToNodes,
@@ -159,6 +160,8 @@ function serializeBlock<
editor as any,
);
+ const isContainer = isContainerNode(editor.pmSchema.nodes[block.type as any]);
+
if (ret.contentDOM && block.content) {
const ic = serializeInlineContentInternalHTML(
editor,
@@ -170,9 +173,15 @@ function serializeBlock<
ret.contentDOM.appendChild(ic);
}
- const pmType = editor.pmSchema.nodes[block.type as any];
-
- if (pmType.isInGroup("bnBlock")) {
+ if (isContainer) {
+ // Mark where the children live so the container's round-trip parse rule
+ // can scope itself to this element (`contentElement` in `getParseRules`).
+ // A render is free to put non-content UI text elsewhere in its DOM
+ // (button labels, captions, ...), and without the marker that text would
+ // parse back as document content.
+ if (ret.contentDOM) {
+ ret.contentDOM.setAttribute("data-children-of", block.type!);
+ }
if (block.children && block.children.length > 0) {
const fragment = serializeBlocks(
editor,
@@ -197,10 +206,30 @@ function serializeBlock<
contentDOM?: HTMLElement;
};
- bc.contentDOM?.appendChild(ret.dom);
+ // Frames wrap the content and its child group in static HTML too. The DOM
+ // render context lets interactive frames export without browser view state.
+ const renderFrame = impl.renderFrame;
+ const frame = renderFrame?.call(
+ {
+ renderType: "dom",
+ props: undefined,
+ blockContentDOMAttributes:
+ editor._tiptapEditor.extensionManager.extensions.find(
+ (extension) => extension.name === block.type,
+ )?.options.domAttributes?.blockContent || {},
+ propSchema: editor.schema.blockSchema[block.type!].propSchema,
+ },
+ { ...block, props, children },
+ editor,
+ );
+ if (frame) {
+ bc.contentDOM?.appendChild(frame.dom);
+ }
+ const contentDOM = frame?.slot ?? bc.contentDOM;
+ contentDOM?.appendChild(ret.dom);
if (block.children && block.children.length > 0) {
- bc.contentDOM?.appendChild(
+ contentDOM?.appendChild(
serializeBlocksInternalHTML(editor, block.children, serializer, options),
);
}
diff --git a/packages/core/src/api/getBlockInfoFromPos.test.ts b/packages/core/src/api/getBlockInfoFromPos.test.ts
index 3e45c14330..5080902c0d 100644
--- a/packages/core/src/api/getBlockInfoFromPos.test.ts
+++ b/packages/core/src/api/getBlockInfoFromPos.test.ts
@@ -1,10 +1,13 @@
+import { containerSchema } from "./blockManipulation/containers/containers.fixture.js";
+import { getNodeById } from "./nodeUtil.js";
import { Node, Schema } from "prosemirror-model";
-import { describe, expect, it } from "vite-plus/test";
+import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
import { blockToNode } from "./nodeConversions/blockToNode.js";
import { docToBlocks } from "./nodeConversions/nodeToBlock.js";
import {
+ getAncestorContainers,
getBlockInfoFromNode,
getLastDescendantBlockInfo,
getNextBlockInfo,
@@ -281,21 +284,6 @@ describe("derived position and content fields", () => {
expect(info.contentKind).toBe("plain");
});
- it("rejects malformed wrapper structure at the block-info boundary", () => {
- const { blockContainer, paragraph, blockGroup } = getSchema().nodes;
- const content = paragraph.create();
- for (const children of [
- [],
- [blockGroup.create()],
- [content, content],
- [content, blockGroup.create(), blockGroup.create()],
- ]) {
- // Deliberately bypass schema checking, as transaction intermediates can.
- const node = blockContainer.create(null, children);
- expect(() => getBlockInfoFromNode(node, 0)).toThrow(/blockContainer/);
- }
- });
-
it("rejects a content node that was not built from a block spec", () => {
// A node dropped straight into the `blockContent` group of a ProseMirror
// schema, with no block spec behind it: nothing declares what its content
@@ -522,3 +510,140 @@ describe("docToBlocks round trip with suggested deletions", () => {
expect(new Set(ids).size).toBe(ids.length);
});
});
+
+describe("block info for containers", () => {
+ let editor: BlockNoteEditor<
+ typeof containerSchema.blockSchema,
+ typeof containerSchema.inlineContentSchema,
+ typeof containerSchema.styleSchema
+ >;
+ beforeEach(() => {
+ editor = BlockNoteEditor.create({ schema: containerSchema });
+ });
+ afterEach(() => {
+ editor._tiptapEditor.destroy();
+ });
+ it.each(["paragraph", "alert", "callout"] as const)(
+ "distinguishes %s ownership from the presence of children",
+ (type) => {
+ for (const children of [
+ [],
+ [{ type: "paragraph" as const, content: "Body" }],
+ ]) {
+ const node = blockToNode({ type, children }, editor.pmSchema);
+ const info = getBlockInfoFromNode(node, 10);
+ expect(info.hasOwnedChildren).toBe(type !== "paragraph");
+ expect(info.hasContent).toBe(type !== "callout");
+ if (children.length) {
+ expect(info.children?.node.childCount).toBe(1);
+ }
+ }
+ },
+ );
+
+ it("rejects malformed wrapper structure at the block-info boundary", () => {
+ const { blockContainer, paragraph, blockGroup } = editor.pmSchema.nodes;
+ const content = paragraph.create();
+ for (const children of [
+ [],
+ [blockGroup.create()],
+ [content, content],
+ [content, blockGroup.create(), blockGroup.create()],
+ ]) {
+ // Deliberately bypass schema checking, as transaction intermediates can.
+ const node = blockContainer.create(null, children);
+ expect(() => getBlockInfoFromNode(node, 0)).toThrow(/blockContainer/);
+ }
+ });
+
+ describe("parent lookups for container children", () => {
+ // Regression: `getParentBlockInfo` used to skip the container level for
+ // container children (returning the grid for a block inside a gridCell).
+ // The parent of a block is the block whose `children` contains it: the
+ // cell.
+ it("returns the container as the parent of its direct children", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "grid",
+ id: "g-0",
+ children: [
+ {
+ type: "gridCell",
+ id: "cell-a",
+ children: [{ id: "cell-a-p", type: "paragraph", content: "A" }],
+ },
+ {
+ type: "gridCell",
+ id: "cell-b",
+ children: [{ id: "cell-b-p", type: "paragraph", content: "B" }],
+ },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.transact((tr) => {
+ // The block directly containing a cell's paragraph is the cell.
+ const cellChild = getNodeById("cell-a-p", tr.doc)!;
+ expect(
+ getParentBlockInfo(tr.doc, cellChild.posBeforeNode)?.blockNoteType,
+ ).toBe("gridCell");
+
+ // The parent of a cell is the grid; the parent of the grid (a
+ // top-level block) is undefined.
+ const cell = getNodeById("cell-a", tr.doc)!;
+ expect(
+ getParentBlockInfo(tr.doc, cell.posBeforeNode)?.blockNoteType,
+ ).toBe("grid");
+
+ const grid = getNodeById("g-0", tr.doc)!;
+ expect(getParentBlockInfo(tr.doc, grid.posBeforeNode)).toBeUndefined();
+ });
+ });
+ });
+
+ it("lists the container ancestors of a position, innermost first", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "grid",
+ id: "g-0",
+ children: [
+ {
+ type: "gridCell",
+ id: "cell-a",
+ children: [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [{ id: "deep-p", type: "paragraph", content: "X" }],
+ },
+ ],
+ },
+ {
+ type: "gridCell",
+ id: "cell-b",
+ children: [{ id: "cell-b-p", type: "paragraph", content: "B" }],
+ },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.transact((tr) => {
+ const deep = getNodeById("deep-p", tr.doc)!;
+ const ancestors = getAncestorContainers(tr.doc, deep.posBeforeNode);
+
+ // Only the container nodes: the `blockGroup`/`blockContainer` levels
+ // between them are not containers and must not be repaired.
+ expect(ancestors.map(({ id }) => id)).toEqual(["c-0", "cell-a", "g-0"]);
+ // Depths shrink outwards, which is what `fixContainersById` sorts on.
+ expect(ancestors.map(({ depth }) => depth)).toEqual(
+ [...ancestors.map(({ depth }) => depth)].sort((a, b) => b - a),
+ );
+
+ // A top-level block has no container ancestors at all.
+ const trailing = getNodeById("trailing", tr.doc)!;
+ expect(getAncestorContainers(tr.doc, trailing.posBeforeNode)).toEqual([]);
+ });
+ });
+});
diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts
index a9512f0f6a..e9712d1b4c 100644
--- a/packages/core/src/api/getBlockInfoFromPos.ts
+++ b/packages/core/src/api/getBlockInfoFromPos.ts
@@ -7,6 +7,11 @@ import {
Transaction,
} from "prosemirror-state";
+import {
+ CHILD_CONTAINER_GROUP,
+ isContainerNode,
+ hasOwnedChildren,
+} from "../schema/blocks/children.js";
import type { BlockConfig } from "../schema/blocks/types.js";
/**
@@ -69,6 +74,7 @@ export type BlockInfo = {
children: ChildrenInfo;
content?: undefined;
hasContent: false;
+ hasOwnedChildren: true;
contentStart?: undefined;
contentEnd?: undefined;
contentKind?: undefined;
@@ -105,6 +111,8 @@ export type BlockInfo = {
* `hasContent: false`.
*/
hasContent: true;
+ /** Whether children belong to this block, even before a body exists. */
+ hasOwnedChildren: boolean;
}
);
@@ -319,9 +327,10 @@ export function getBlockInfoFromNode(node: Node, beforePos: number): BlockInfo {
afterPos: beforePos + node.nodeSize,
};
- if (node.type.isInGroup("bnBlock") && node.type.isInGroup("childContainer")) {
+ if (isContainerNode(node.type)) {
return {
hasContent: false,
+ hasOwnedChildren: true,
block,
children: {
...block,
@@ -375,6 +384,7 @@ export function getBlockInfoFromNode(node: Node, beforePos: number): BlockInfo {
return {
hasContent: true,
+ hasOwnedChildren: hasOwnedChildren(node),
block,
content,
children,
@@ -452,7 +462,7 @@ export function getParentBlockInfo(
}
// A `blockGroup`: its own parent block is the real parent, unless it's the
// document root group.
- if (parent.type.isInGroup("childContainer") && $pos.depth > 1) {
+ if (parent.type.isInGroup(CHILD_CONTAINER_GROUP) && $pos.depth > 1) {
return getBlockInfoAt(doc, $pos.before($pos.depth - 1));
}
return undefined;
@@ -583,13 +593,7 @@ export function getInsertionPos(
// A restricted container can route insertion into its edge container,
// e.g. inserting a paragraph into the last column of a column list.
const child = last ? children.node.lastChild : children.node.firstChild;
- if (
- !child ||
- !(
- child.type.isInGroup("bnBlock") &&
- child.type.isInGroup("childContainer")
- )
- ) {
+ if (!child || !isContainerNode(child.type)) {
break;
}
info = getBlockInfoFromNode(
@@ -599,3 +603,71 @@ export function getInsertionPos(
}
return null;
}
+
+/**
+ * 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.
+ */
+export function getFirstLeafBlock(info: BlockInfo): BlockInfo | null {
+ while (!info.hasContent) {
+ const { node, childrenStart } = info.children;
+ if (!node.firstChild) {
+ return null;
+ }
+ info = getBlockInfoFromNode(node.firstChild, childrenStart);
+ }
+ return info;
+}
+
+/**
+ * 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)) {
+ 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/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts
index af5c0ba1b7..5f46f4b418 100644
--- a/packages/core/src/api/nodeConversions/blockToNode.ts
+++ b/packages/core/src/api/nodeConversions/blockToNode.ts
@@ -16,6 +16,10 @@ import {
isPartialLinkInlineContent,
isStyledTextInlineContent,
} from "../../schema/inlineContent/types.js";
+import {
+ createBlockGroup,
+ isContainerNode,
+} from "../../schema/blocks/children.js";
import { getColspan, isPartialTableCell } from "../../util/table.js";
import { UnreachableCaseError } from "../../util/typescript.js";
import { getAbsoluteTableCells } from "../blockManipulation/tables/tables.js";
@@ -288,13 +292,17 @@ export function tableContentToNodes<
return rowNodes;
}
+/**
+ * Converts a block's (or custom inline content element's) `content` field to a
+ * `blockContent` (or custom inline content) prosemirror node.
+ */
function blockOrInlineContentToContentNode(
block:
| PartialBlock
| PartialCustomInlineContentFromConfig,
schema: Schema,
styleSchema: StyleSchema,
-) {
+): Node {
let contentNode: Node;
let type = block.type;
@@ -334,6 +342,25 @@ function blockOrInlineContentToContentNode(
return contentNode;
}
+// `createAndFill` fills with schema defaults, which leaves `id: null`. Always
+// rebuilds: the only inputs are freshly created nodes, so there is no shared
+// structure worth preserving.
+function withGeneratedIds(node: Node): Node {
+ if (node.isText) {
+ return node;
+ }
+
+ const children: Node[] = [];
+ node.forEach((child) => children.push(withGeneratedIds(child)));
+
+ const needsId = node.type.isInGroup("bnBlock") && node.attrs.id === null;
+ return node.type.create(
+ needsId ? { ...node.attrs, id: UniqueID.options.generateID() } : node.attrs,
+ Fragment.from(children),
+ node.marks,
+ );
+}
+
/**
* Converts a BlockNote block to a Prosemirror node.
*/
@@ -361,8 +388,6 @@ export function blockToNode(
schema.nodes[block.type].isInGroup("blockContent");
if (isBlockContent) {
- // Blocks with a type that matches "blockContent" group always need to be wrapped in a blockContainer
-
const contentNode = blockOrInlineContentToContentNode(
block,
schema,
@@ -370,9 +395,7 @@ export function blockToNode(
);
const groupNode =
- children.length > 0
- ? schema.nodes["blockGroup"].createChecked({}, children)
- : undefined;
+ children.length > 0 ? createBlockGroup(schema, children) : undefined;
return schema.nodes["blockContainer"].createChecked(
{
@@ -381,16 +404,21 @@ export function blockToNode(
},
groupNode ? [contentNode, groupNode] : contentNode,
);
- } else if (schema.nodes[block.type].isInGroup("bnBlock")) {
- // `create` (not `createChecked`) so partial container blocks pass through;
- // callers that mutate the doc validate via `node.check()` before inserting.
- return schema.nodes[block.type].create(
- {
- id: id,
- ...block.props,
- },
- children,
- );
+ } else if (isContainerNode(schema.nodes[block.type])) {
+ const type = schema.nodes[block.type];
+ const attrs = { id: id, ...block.props };
+
+ // Fill missing children up to the configured minimum, including for an
+ // explicit empty array. Generated descendants need block IDs as well.
+ const node = type.createAndFill(attrs, children);
+ if (!node) {
+ throw new Error(
+ `Cannot create block "${block.type}": its children don't fit its \`children\` config ` +
+ `(it accepts \`${type.spec.content}\`).`,
+ );
+ }
+
+ return withGeneratedIds(node);
} else {
throw new Error(
`block type ${block.type} doesn't match blockContent or bnBlock group`,
diff --git a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts
index 19f063d8bb..101c87020a 100644
--- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts
+++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts
@@ -1,63 +1,44 @@
-import { Fragment } from "@tiptap/pm/model";
+import { Fragment, Node } from "@tiptap/pm/model";
import {
BlockNoDefaults,
BlockSchema,
InlineContentSchema,
StyleSchema,
} from "../../schema/index.js";
+import { isContainerNode, isNamedOnly } from "../../schema/blocks/children.js";
import { nodeToBlock } from "./nodeToBlock.js";
-/**
- * Converts all Blocks within a fragment to BlockNote blocks.
- */
export function fragmentToBlocks<
B extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
>(fragment: Fragment) {
- // first convert selection to blocknote-style blocks, and then
- // pass these to the exporter
const blocks: BlockNoDefaults[] = [];
- fragment.descendants((node) => {
- if (node.type.name === "blockContainer") {
- if (node.firstChild?.type.name === "blockGroup") {
- // selection started within a block group
- // in this case the fragment starts with:
- //
- //
- //
- //
- //
- //
- //
- // instead of:
- //
- //
- //
- //
- //
- //
- //
- //
- // so we don't need to serialize this block, just descend into the children of the blockGroup
- return true;
- }
- }
- if (node.type.name === "columnList" && node.childCount === 1) {
- // column lists with a single column should be flattened (not the entire column list has been selected)
- node.firstChild?.forEach((child) => {
- blocks.push(nodeToBlock(child, node));
- });
- return false;
- }
+ function visit(node: Node, root: Node) {
+ const childrenConfig = node.type.spec.blockConfig?.children;
+ const incompleteBlock =
+ node.type.name === "blockContainer" &&
+ node.firstChild?.type.name === "blockGroup";
+ const flattenContainer =
+ isContainerNode(node.type) &&
+ (!childrenConfig ||
+ isNamedOnly(node.type) ||
+ node.childCount < (childrenConfig.min ?? 1));
- if (node.type.isInGroup("bnBlock")) {
- blocks.push(nodeToBlock(node, node));
- // don't descend into children, as they're already included in the block returned by nodeToBlock
- return false;
+ // Open selections and containers that cannot stand alone contribute
+ // their children. Complete blocks already include their descendants.
+ if (
+ incompleteBlock ||
+ flattenContainer ||
+ !node.type.isInGroup("bnBlock")
+ ) {
+ node.forEach((child) => visit(child, flattenContainer ? root : child));
+ } else {
+ blocks.push(nodeToBlock(node, root));
}
- return true;
- });
+ }
+
+ fragment.forEach((node) => visit(node, node));
return blocks;
}
diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts
index 004b7fd44b..ff890c09ac 100644
--- a/packages/core/src/api/nodeConversions/nodeToBlock.ts
+++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts
@@ -1,5 +1,6 @@
import { Mark, Node, Slice } from "@tiptap/pm/model";
import type { Block } from "../../blocks/defaultBlocks.js";
+import { isContainerNode } from "../../schema/blocks/children.js";
import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js";
import type {
BlockSchema,
@@ -399,7 +400,7 @@ export function nodeToBlock<
const styleSchema = getStyleSchema(schema) as S;
const blockCache = getBlockCache(schema);
if (!node.type.isInGroup("bnBlock")) {
- throw Error("Node should be a bnBlock, but is instead: " + node.type.name);
+ throw Error("Node should be a block, but is instead: " + node.type.name);
}
const cachedBlock = blockCache?.get(node);
@@ -418,9 +419,9 @@ export function nodeToBlock<
id = UniqueID.options.generateID();
}
- const blockSpec = blockSchema[blockInfo.blockNoteType];
+ const blockConfig = blockSchema[blockInfo.blockNoteType];
- if (!blockSpec) {
+ if (!blockConfig) {
throw Error("Block is of an unrecognized type: " + blockInfo.blockNoteType);
}
@@ -429,7 +430,7 @@ export function nodeToBlock<
...node.attrs,
...(blockInfo.hasContent ? blockInfo.content.node.attrs : {}),
})) {
- const propSchema = blockSpec.propSchema;
+ const propSchema = blockConfig.propSchema;
if (
attr in propSchema &&
@@ -439,8 +440,6 @@ export function nodeToBlock<
}
}
- const blockConfig = blockSchema[blockInfo.blockNoteType];
-
const children: Block[] = [];
blockInfo.children?.node.forEach((child) => {
children.push(nodeToBlock(child, doc));
@@ -560,7 +559,9 @@ export function prosemirrorSliceToSlicedBlocks<
blockCutAtStart: string | undefined;
blockCutAtEnd: string | undefined;
} {
- if (node.type.name !== "blockGroup") {
+ // Both `blockGroup` and container nodes (columnList, column, callout,
+ // ...) hold block children directly, so both can be processed here.
+ if (node.type.name !== "blockGroup" && !isContainerNode(node.type)) {
throw new Error("unexpected");
}
const blocks: Block[] = [];
@@ -568,37 +569,46 @@ export function prosemirrorSliceToSlicedBlocks<
let blockCutAtEnd: string | undefined;
node.forEach((blockContainer, _offset, index) => {
- if (blockContainer.type.name !== "blockContainer") {
- throw new Error("unexpected");
- }
- if (blockContainer.childCount === 0) {
- return;
- }
- if (blockContainer.childCount === 0 || blockContainer.childCount > 2) {
- throw new Error(
- "unexpected, blockContainer.childCount: " + blockContainer.childCount,
- );
- }
-
const isFirstBlock = index === 0;
const isLastBlock = index === node.childCount - 1;
- if (blockContainer.firstChild!.type.name === "blockGroup") {
- // this is the parent where a selection starts within one of its children,
- // e.g.:
- // A
- // ├── B
- // selection starts within B, then this blockContainer is A, but we don't care about A
- // so let's descend into B and continue processing
- if (!isFirstBlock) {
+ const isContainer = isContainerNode(blockContainer.type);
+ if (!isContainer) {
+ if (blockContainer.type.name !== "blockContainer") {
throw new Error("unexpected");
}
+ if (blockContainer.childCount === 0) {
+ return;
+ }
+ if (blockContainer.childCount > 2) {
+ throw new Error(
+ "unexpected, blockContainer.childCount: " +
+ blockContainer.childCount,
+ );
+ }
+ }
+
+ const omittedParent =
+ !isContainer && blockContainer.firstChild!.type.name === "blockGroup";
+ if (omittedParent && !isFirstBlock) {
+ throw new Error("unexpected");
+ }
+
+ // Open containers and regular parents whose content was cut away both
+ // contribute their selected children, without their own wrapper.
+ if (
+ omittedParent ||
+ (isContainer &&
+ ((isFirstBlock && openStart > 0) || (isLastBlock && openEnd > 0)))
+ ) {
const ret = processNode(
- blockContainer.firstChild!,
- Math.max(0, openStart - 1),
+ isContainer ? blockContainer : blockContainer.firstChild!,
+ isFirstBlock ? Math.max(0, openStart - 1) : 0,
isLastBlock ? Math.max(0, openEnd - 1) : 0,
);
- blockCutAtStart = ret.blockCutAtStart;
+ if (isFirstBlock) {
+ blockCutAtStart = ret.blockCutAtStart;
+ }
if (isLastBlock) {
blockCutAtEnd = ret.blockCutAtEnd;
}
@@ -606,6 +616,13 @@ export function prosemirrorSliceToSlicedBlocks<
return;
}
+ if (isContainer) {
+ blocks.push(
+ nodeToBlock(blockContainer, slice.content.firstChild!),
+ );
+ return;
+ }
+
const block = nodeToBlock(blockContainer, slice.content.firstChild!);
const childGroup =
blockContainer.childCount > 1 ? blockContainer.child(1) : undefined;
diff --git a/packages/core/src/blocks/ListItem/CheckListItem/block.test.ts b/packages/core/src/blocks/ListItem/CheckListItem/block.test.ts
index b1da899571..9715bc503e 100644
--- a/packages/core/src/blocks/ListItem/CheckListItem/block.test.ts
+++ b/packages/core/src/blocks/ListItem/CheckListItem/block.test.ts
@@ -34,7 +34,7 @@ it("renders checkbox as enabled when editor is editable", () => {
children: [],
};
const spec = editor.schema.blockSpecs.checkListItem;
- const view = spec.implementation.render(block, editor);
+ const view = spec.implementation.render!(block, editor);
const checkbox = getCheckboxFromView(view);
expect(checkbox.disabled).toBe(false);
});
@@ -55,7 +55,7 @@ it("renders checkbox as disabled when editor is not editable", () => {
children: [],
};
const spec = editor.schema.blockSpecs.checkListItem;
- const view = spec.implementation.render(block, editor);
+ const view = spec.implementation.render!(block, editor);
const checkbox = getCheckboxFromView(view);
expect(checkbox.disabled).toBe(true);
});
diff --git a/packages/core/src/editor/managers/BlockManager.ts b/packages/core/src/editor/managers/BlockManager.ts
index 5b16ec0fcc..dbae8b1281 100644
--- a/packages/core/src/editor/managers/BlockManager.ts
+++ b/packages/core/src/editor/managers/BlockManager.ts
@@ -1,5 +1,5 @@
+import type { BlockPlacement } from "../../api/getBlockInfoFromPos.js";
import { insertBlocks } from "../../api/blockManipulation/commands/insertBlocks/insertBlocks.js";
-import { BlockPlacement } from "../../api/getBlockInfoFromPos.js";
import {
moveBlocksDown,
moveBlocksUp,
diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
index 853cca2493..c1626636b4 100644
--- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts
+++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
@@ -39,6 +39,7 @@ import {
UniqueID,
} from "../../../extensions/tiptap-extensions/index.js";
import { BlockContainer, BlockGroup, Doc } from "../../../pm-nodes/index.js";
+import { isContainerConfig } from "../../../schema/blocks/children.js";
import type {
BlockNoteEditor,
BlockNoteEditorOptions,
@@ -62,7 +63,13 @@ export function getDefaultTiptapExtensions(
UniqueID.configure({
// everything from bnBlock group (nodes that represent a BlockNote block should have an id)
- types: ["blockContainer", "columnList", "column"],
+ types: [
+ "blockContainer",
+ // Only pure containers own their ID; titled blocks use blockContainer.
+ ...Object.values(editor.schema.blockSpecs)
+ .filter((spec) => isContainerConfig(spec.config))
+ .map((spec) => spec.config.type),
+ ],
setIdAttribute: options.setIdAttribute,
isWithinEditor: editor.isWithinEditor,
}),
diff --git a/packages/core/src/exporter/Exporter.test.ts b/packages/core/src/exporter/Exporter.test.ts
index 98e7b34f1c..39f49f2ed7 100644
--- a/packages/core/src/exporter/Exporter.test.ts
+++ b/packages/core/src/exporter/Exporter.test.ts
@@ -83,7 +83,12 @@ describe("Exporter mapping typing", () => {
...defaultBlockSpecs,
extraBlock: createBlockSpec(
{ content: "none", type: "extraBlock", propSchema: {} },
- {} as any,
+ {
+ render: () => {
+ const dom = document.createElement("div");
+ return { dom, contentDOM: dom };
+ },
+ } as any,
)(),
},
});
@@ -184,3 +189,31 @@ describe("Exporter missing mappings", () => {
).toThrow('missing a style mapping for style "bold"');
});
});
+
+describe("Exporter block types outside its schema", () => {
+ it("treats a childless block of an unknown type as a regular block", () => {
+ // Block packages (math, diagram, ...) commonly supply only a mapping,
+ // which reads the block's JSON - their specs need not be in the schema.
+ expect(
+ new EmptyMappingsExporter().isContainerBlock({
+ type: "mathBlock",
+ children: [],
+ }),
+ ).toBe(false);
+ });
+
+ it("throws when a block of an unknown type has children", () => {
+ // Ambiguous: without the spec there is no way to tell whether the
+ // mapping places these children itself (container) or the exporter
+ // appends them (regular block), and guessing puts them in the wrong
+ // place silently.
+ expect(() =>
+ new EmptyMappingsExporter().isContainerBlock({
+ type: "columnList",
+ children: [{ type: "column" }],
+ }),
+ ).toThrow(
+ 'Exporter has no block spec for block type "columnList", and blocks of that type in this document have children',
+ );
+ });
+});
diff --git a/packages/core/src/exporter/Exporter.ts b/packages/core/src/exporter/Exporter.ts
index 6ca0efa222..babd4b1cc6 100644
--- a/packages/core/src/exporter/Exporter.ts
+++ b/packages/core/src/exporter/Exporter.ts
@@ -3,7 +3,7 @@ import { COLORS_DEFAULT } from "../editor/defaultColors.js";
import type { Dictionary } from "../i18n/dictionary.js";
import { en } from "../i18n/locales/index.js";
import {
- BlockFromConfig,
+ BlockNoDefaults,
BlockSchema,
InlineContent,
InlineContentSchema,
@@ -61,7 +61,7 @@ export abstract class Exporter<
TS,
> {
public constructor(
- _schema: BlockNoteSchema, // only used for type inference
+ protected readonly schema: BlockNoteSchema,
protected readonly mappings: {
blockMapping: BlockMapping;
inlineContentMapping: InlineContentMapping;
@@ -70,6 +70,20 @@ export abstract class Exporter<
public readonly options: ExporterOptions,
) {}
+ /** Container mappings place their own children; regular mappings do not. */
+ public isContainerBlock(block: {
+ type: string;
+ children?: unknown[];
+ }): boolean {
+ const spec = this.schema.blockSpecs[block.type];
+ if (!spec && block.children?.length) {
+ throw new Error(
+ `Exporter has no block spec for block type "${block.type}", and blocks of that type in this document have children. Add its spec to the exporter schema so it can determine who renders the children.`,
+ );
+ }
+ return spec?.config.children !== undefined;
+ }
+
/**
* The strings this exporter renders into the produced document - the
* `exporter` section of the configured dictionary (the `dictionary`
@@ -139,7 +153,7 @@ export abstract class Exporter<
public abstract transformStyledText(styledText: StyledText): TS;
public async mapBlock(
- block: BlockFromConfig,
+ block: BlockNoDefaults,
nestingLevel: number,
numberedListIndex: number,
children?: Array>,
@@ -147,7 +161,9 @@ export abstract class Exporter<
const mapping = this.mappings.blockMapping[block.type];
if (!mapping) {
throw new Error(
- `Exporter is missing a block mapping for block type "${block.type}". If this block comes from a separate package, spread that package's exporter mappings into your blockMapping.`,
+ this.isContainerBlock(block)
+ ? `No mapping found for container block type "${block.type}". Container blocks require an explicit block mapping that places their children.`
+ : `Exporter is missing a block mapping for block type "${block.type}". If this block comes from a separate package, spread that package's exporter mappings into your blockMapping.`,
);
}
return mapping(block, this, nestingLevel, numberedListIndex, children);
diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts
index fddd2712e9..1300fa2112 100644
--- a/packages/core/src/extensions/SideMenu/SideMenu.ts
+++ b/packages/core/src/extensions/SideMenu/SideMenu.ts
@@ -20,8 +20,16 @@ import {
InlineContentSchema,
StyleSchema,
} from "../../schema/index.js";
-import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js";
+import {
+ CONTAINER_SELECTOR,
+ getBlockFromElement,
+ getDraggableBlockFromElement,
+} from "../blockDOM.js";
import { dragStart, unsetDragImage } from "./dragging.js";
+import {
+ getNestedBlockAtCursor,
+ getDirectChildBlocks,
+} from "./sideMenuContainerGeometry.js";
export type SideMenuState<
BSchema extends BlockSchema,
@@ -37,7 +45,6 @@ const DISTANCE_TO_CONSIDER_EDITOR_BOUNDS = 250;
function getBlockFromCoords(
view: EditorView,
coords: { left: number; top: number },
- adjustForColumns = true,
) {
const elements = view.root.elementsFromPoint(coords.left, coords.top);
@@ -46,21 +53,15 @@ function getBlockFromCoords(
// probably a ui overlay like formatting toolbar etc
continue;
}
- if (adjustForColumns) {
- const column = element.closest("[data-node-type=columnList]");
- if (column) {
- return getBlockFromCoords(
- view,
- {
- // TODO can we do better than this?
- left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself
- top: coords.top,
- },
- false,
- );
- }
- }
- return getDraggableBlockFromElement(element, view);
+ const block = getBlockFromElement(element, view);
+ return block
+ ? {
+ ...block,
+ // Controls in a container's chrome belong to that container, even
+ // when they share a row with one of its child blocks.
+ isControl: !!element.closest('[contenteditable="false"]'),
+ }
+ : undefined;
}
return undefined;
}
@@ -71,6 +72,7 @@ function getBlockFromMousePos(
y: number;
},
view: EditorView,
+ isDraggable: (type: string) => boolean,
): { node: HTMLElement; id: string } | undefined {
// Editor itself may have padding or other styling which affects
// size/position, so we get the boundingRect of the first child (i.e. the
@@ -101,6 +103,10 @@ function getBlockFromMousePos(
return undefined;
}
+ if (referenceBlock.isControl) {
+ return getDraggableBlockFromElement(referenceBlock.node, view, isDraggable);
+ }
+
/**
* Because blocks may be nested, we need to check the right edge of the parent block:
* ```
@@ -109,17 +115,26 @@ function getBlockFromMousePos(
* ```
* Hovering at position x (left edge of BlockB) would return BlockA.
* Instead, we check at position y (right edge of BlockA) to correctly identify BlockB.
+ * `elementsFromPoint` returns the deepest element at a point, so this single
+ * probe descends through any depth of regular nesting.
+ *
+ * For a container block, the probe is
+ * aimed at the innermost child under the cursor instead of the container
+ * itself. The container's own padding can exceed the probe inset, which
+ * would keep resolving the container even though the cursor is aligned with
+ * one of its children (making the child's menu jump away as the cursor
+ * moves towards it).
*/
- const referenceBlocksBoundingBox =
- referenceBlock.node.getBoundingClientRect();
- return getBlockFromCoords(
- view,
- {
- left: referenceBlocksBoundingBox.right - 10,
- top: mousePos.y,
- },
- false,
- );
+ const probeTarget = getNestedBlockAtCursor(referenceBlock.node, mousePos);
+ const target = getBlockFromCoords(view, {
+ left: probeTarget.getBoundingClientRect().right - 10,
+ top: mousePos.y,
+ });
+ // Resolve layout before applying drag policy: columns have no handle, but
+ // their children do, and their gutter still needs to resolve those children.
+ return target
+ ? getDraggableBlockFromElement(target.node, view, isDraggable)
+ : undefined;
}
/**
@@ -135,8 +150,6 @@ export class SideMenuView<
private mousePos: { x: number; y: number } | undefined;
- private hoveredBlock: HTMLElement | undefined;
-
public menuFrozen = false;
public isDragOrigin = false;
@@ -214,7 +227,11 @@ export class SideMenuView<
return;
}
- const block = getBlockFromMousePos(this.mousePos, this.pmView);
+ const blockSpecs = this.editor.schema.blockSpecs;
+ function isDraggable(type: string) {
+ return blockSpecs[type].implementation.meta?.draggable !== false;
+ }
+ const block = getBlockFromMousePos(this.mousePos, this.pmView, isDraggable);
// Closes the menu if the mouse cursor is beyond the editor vertically.
if (!block || !this.editor.isEditable) {
@@ -227,51 +244,48 @@ export class SideMenuView<
}
// Doesn't update if the menu is already open and the mouse cursor is still hovering the same block.
- if (
- this.state?.show &&
- this.hoveredBlock?.hasAttribute("data-id") &&
- this.hoveredBlock?.getAttribute("data-id") === block.id
- ) {
+ if (this.state?.show && this.state.block.id === block.id) {
return;
}
- this.hoveredBlock = block.node;
-
- // Shows or updates elements.
- if (this.editor.isEditable) {
- const blockContentBoundingBox = block.node.getBoundingClientRect();
- const column = block.node.closest("[data-node-type=column]");
- const sideMenuBlock = this.editor.getBlock(
- this.hoveredBlock!.getAttribute("data-id")!,
- );
- if (!sideMenuBlock) {
- if (this.state?.show) {
- this.state.show = false;
- this.hoveredBlock = undefined;
- this.emitUpdate(this.state);
- }
- return;
+ const blockContentBoundingBox = block.node.getBoundingClientRect();
+ // The closest container ancestor (a column, callout, ...), excluding
+ // the hovered block itself, which may be a draggable container. Blocks
+ // inside a container anchor the side menu to the container's block
+ // area rather than the editor's left edge, which would put the menu
+ // over unrelated content (or off-screen inside columns).
+ const container = block.node.parentElement?.closest(CONTAINER_SELECTOR);
+ const sideMenuBlock = this.editor.getBlock(block.id);
+ if (!sideMenuBlock) {
+ if (this.state?.show) {
+ this.state.show = false;
+ this.emitUpdate(this.state);
}
- this.state = {
- show: true,
- referencePos: new DOMRect(
- column
- ? // We take the first child as column elements have some default
- // padding. This is a little weird since this child element will
- // be the first block, but since it's always non-nested and we
- // only take the x coordinate, it's ok.
- column.firstElementChild!.getBoundingClientRect().x
- : (
- this.pmView.dom.firstChild as HTMLElement
- ).getBoundingClientRect().x,
- blockContentBoundingBox.y,
- blockContentBoundingBox.width,
- blockContentBoundingBox.height,
- ),
- block: sideMenuBlock,
- };
- this.updateState(this.state);
+ return;
}
+ this.state = {
+ show: true,
+ referencePos: new DOMRect(
+ container
+ ? // We anchor to the container's first child block (rather than
+ // the container itself, which may have padding or its own
+ // chrome around the block area). This is a little weird since
+ // this element is the first block, but since it's always
+ // non-nested and we only take the x coordinate, it's ok.
+ (
+ getDirectChildBlocks(container)[0] ??
+ container.firstElementChild ??
+ container
+ ).getBoundingClientRect().x
+ : (this.pmView.dom.firstChild as HTMLElement).getBoundingClientRect()
+ .x,
+ blockContentBoundingBox.y,
+ blockContentBoundingBox.width,
+ blockContentBoundingBox.height,
+ ),
+ block: sideMenuBlock,
+ };
+ this.updateState(this.state);
};
/**
diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts
new file mode 100644
index 0000000000..7d7ab91d09
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts
@@ -0,0 +1,169 @@
+import { afterEach, describe, expect, it } from "vite-plus/test";
+
+import {
+ getNestedBlockAtCursor,
+ getDirectChildBlocks,
+} from "./sideMenuContainerGeometry.js";
+
+// Exercise container hit testing with real layout, including nested columns.
+
+/** Attaches a tree to the document so the browser actually lays it out. */
+function mount(el: T): T {
+ document.body.appendChild(el);
+ mounted.push(el);
+ return el;
+}
+
+let mounted: HTMLElement[] = [];
+
+afterEach(() => {
+ mounted.forEach((el) => el.remove());
+ mounted = [];
+});
+
+function el(nodeType: string): HTMLElement {
+ const node = document.createElement("div");
+ node.setAttribute("data-node-type", nodeType);
+ node.setAttribute("data-id", crypto.randomUUID());
+ return node;
+}
+
+/** The `blockOuter > blockContainer` chrome BlockNote renders around every
+ * regular block, with real text in it so it has a real height. */
+function regularChild(text = "block"): {
+ outer: HTMLElement;
+ blockContainer: HTMLElement;
+} {
+ const outer = el("blockOuter");
+ const blockContainer = el("blockContainer");
+ blockContainer.textContent = text;
+ outer.append(blockContainer);
+ return { outer, blockContainer };
+}
+
+/**
+ * A column list laid out the way the real one is: a flex row of two columns,
+ * each holding one block. Nothing declares "horizontal". The browser puts the
+ * columns side by side and the module has to notice.
+ */
+function buildColumnList() {
+ const columnList = el("columnList");
+ columnList.style.display = "flex";
+ columnList.style.width = "400px";
+
+ const columnA = el("column");
+ const columnB = el("column");
+ for (const column of [columnA, columnB]) {
+ column.style.flex = "1";
+ }
+
+ const childA = regularChild("A");
+ const childB = regularChild("B");
+ columnA.append(childA.outer);
+ columnB.append(childB.outer);
+ columnList.append(columnA, columnB);
+ mount(columnList);
+
+ return { columnList, columnA, columnB, childA, childB };
+}
+
+/** A callout: an ordinary block-flow container, so its children stack. */
+function buildVerticalContainer() {
+ const callout = el("callout");
+ callout.style.width = "400px";
+ const first = regularChild("first");
+ const second = regularChild("second");
+ callout.append(first.outer, second.outer);
+ mount(callout);
+
+ return { callout, first, second };
+}
+
+describe("getDirectChildBlocks", () => {
+ it("returns direct child blocks, skipping nested grandchildren", () => {
+ const { columnList, columnA, columnB } = buildColumnList();
+
+ // The blocks inside each column must not come back as the list's own
+ // children. The `closest` check stops the walk one level down.
+ expect(getDirectChildBlocks(columnList)).toEqual([columnA, columnB]);
+ });
+
+ it("sees through blockOuter wrappers to the blockContainer child", () => {
+ const { columnA, childA } = buildColumnList();
+
+ // The column's own direct child is the wrapped blockContainer, not the
+ // blockOuter chrome (which isn't a block in the selector's sense).
+ expect(getDirectChildBlocks(columnA)).toEqual([childA.blockContainer]);
+ });
+});
+
+describe("getNestedBlockAtCursor", () => {
+ it("keeps a regular block as the probe target", () => {
+ const { childA } = buildColumnList();
+ expect(
+ getNestedBlockAtCursor(childA.blockContainer, { x: 10, y: 10 }),
+ ).toBe(childA.blockContainer);
+ });
+
+ it("descends into the hovered column's block", () => {
+ const { columnList, childA, childB } = buildColumnList();
+ for (const child of [childA, childB]) {
+ const rect = child.blockContainer.getBoundingClientRect();
+ expect(
+ getNestedBlockAtCursor(columnList, {
+ x: rect.left + rect.width / 2,
+ y: rect.top + rect.height / 2,
+ }),
+ ).toBe(child.blockContainer);
+ }
+ });
+
+ it("finds a stacked child from the container gutter", () => {
+ const { callout, second } = buildVerticalContainer();
+ const rect = second.blockContainer.getBoundingClientRect();
+ expect(
+ getNestedBlockAtCursor(callout, {
+ x: rect.left - 20,
+ y: rect.top + rect.height / 2,
+ }),
+ ).toBe(second.blockContainer);
+ });
+
+ it("finds a framed block's child when moving into the side-menu gutter", () => {
+ const parent = regularChild("");
+ const frame = document.createElement("div");
+ frame.style.padding = "12px 16px";
+ const title = document.createElement("div");
+ title.textContent = "Callout title";
+ const child = regularChild("Callout body");
+ frame.append(title, child.outer);
+ parent.blockContainer.append(frame);
+ parent.outer.style.width = "400px";
+ mount(parent.outer);
+
+ const rect = child.blockContainer.getBoundingClientRect();
+ for (const x of [rect.left + 20, rect.left - 12, rect.left - 40]) {
+ expect(
+ getNestedBlockAtCursor(parent.blockContainer, {
+ x,
+ y: rect.top + rect.height / 2,
+ }),
+ ).toBe(child.blockContainer);
+ }
+ const titleRect = title.getBoundingClientRect();
+ expect(
+ getNestedBlockAtCursor(parent.blockContainer, {
+ x: titleRect.left,
+ y: titleRect.top + titleRect.height / 2,
+ }),
+ ).toBe(parent.blockContainer);
+ });
+
+ it("keeps the container when the cursor misses its children", () => {
+ const { callout } = buildVerticalContainer();
+ const rect = callout.getBoundingClientRect();
+ expect(
+ getNestedBlockAtCursor(callout, { x: rect.left, y: rect.bottom + 10 }),
+ ).toBe(callout);
+ });
+});
diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts
new file mode 100644
index 0000000000..926271b5a1
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ rectIndexAtCursor,
+ type BlockRect,
+} from "./sideMenuContainerGeometry.js";
+
+// Hit testing works for both side-by-side and stacked children.
+
+const rect = (
+ top: number,
+ bottom: number,
+ left: number,
+ right: number,
+): BlockRect => ({ top, bottom, left, right });
+
+// Two columns of a column list: same vertical band, adjacent horizontally.
+const SIDE_BY_SIDE = [rect(0, 100, 0, 100), rect(0, 100, 100, 200)];
+// Two blocks of a callout: same horizontal band, stacked vertically.
+const STACKED = [rect(0, 40, 0, 200), rect(50, 90, 0, 200)];
+
+describe("rectIndexAtCursor", () => {
+ it("returns the rect whose x range contains the cursor (side-by-side)", () => {
+ // Both rects share the y range, so only x distinguishes them. The
+ // vertical-only fallback recorded for the first must not win over an x
+ // match found later in the list; otherwise hovering the second column of
+ // a row would resolve to its neighbour.
+ expect(rectIndexAtCursor(SIDE_BY_SIDE, { x: 150, y: 50 })).toBe(1);
+ expect(rectIndexAtCursor(SIDE_BY_SIDE, { x: 10, y: 50 })).toBe(0);
+ });
+
+ it("falls back to the first vertical match when x is in the gutter", () => {
+ // The cursor's y is in the first block's band but its x is left of it (the
+ // side-menu gutter). The first vertical match wins.
+ expect(rectIndexAtCursor(STACKED, { x: -20, y: 20 })).toBe(0);
+ });
+
+ it("returns undefined when the cursor misses every rect vertically", () => {
+ expect(rectIndexAtCursor(STACKED, { x: 10, y: 999 })).toBeUndefined();
+ expect(rectIndexAtCursor(STACKED, { x: 10, y: -999 })).toBeUndefined();
+ expect(rectIndexAtCursor([], { x: 10, y: 10 })).toBeUndefined();
+ });
+
+ it("includes the rect edges", () => {
+ const single = [rect(0, 40, 0, 200)];
+ expect(rectIndexAtCursor(single, { x: 0, y: 0 })).toBe(0);
+ expect(rectIndexAtCursor(single, { x: 200, y: 40 })).toBe(0);
+ });
+});
diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts
new file mode 100644
index 0000000000..dc4ae46c40
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts
@@ -0,0 +1,56 @@
+import { BLOCK_SELECTOR } from "../blockDOM.js";
+
+export function getDirectChildBlocks(container: Element): Element[] {
+ const children: Element[] = [];
+ for (const child of container.querySelectorAll(BLOCK_SELECTOR)) {
+ if (child.parentElement?.closest(BLOCK_SELECTOR) === container) {
+ children.push(child);
+ }
+ }
+ return children;
+}
+
+export type BlockRect = {
+ top: number;
+ bottom: number;
+ left: number;
+ right: number;
+};
+
+// X-match wins over y-only match (disambiguates side-by-side children).
+export function rectIndexAtCursor(
+ rects: BlockRect[],
+ mousePos: { x: number; y: number },
+): number | undefined {
+ let verticalMatch: number | undefined = undefined;
+ for (let i = 0; i < rects.length; i++) {
+ const rect = rects[i];
+ if (mousePos.y < rect.top || mousePos.y > rect.bottom) {
+ continue;
+ }
+ if (mousePos.x >= rect.left && mousePos.x <= rect.right) {
+ return i;
+ }
+ verticalMatch = verticalMatch ?? i;
+ }
+ return verticalMatch;
+}
+
+// Descend through child blocks, including regular blocks with padded frames.
+export function getNestedBlockAtCursor(
+ element: Element,
+ mousePos: { x: number; y: number },
+): Element {
+ while (element.matches(BLOCK_SELECTOR)) {
+ const children = getDirectChildBlocks(element);
+ const index = rectIndexAtCursor(
+ children.map((child) => child.getBoundingClientRect()),
+ mousePos,
+ );
+ if (index === undefined) {
+ break;
+ }
+ element = children[index];
+ }
+ return element;
+}
diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts
index bb396fbdd7..5abdbfab8d 100644
--- a/packages/core/src/extensions/TableHandles/TableHandles.ts
+++ b/packages/core/src/extensions/TableHandles/TableHandles.ts
@@ -42,7 +42,7 @@ import {
BlockFromConfigNoChildren,
BlockSchemaWithBlock,
} from "../../schema/index.js";
-import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js";
+import { getBlockFromElement } from "../blockDOM.js";
let dragImageElement: HTMLElement | undefined;
@@ -246,8 +246,9 @@ export class TableHandlesView implements PluginView {
const tableRect = target.tbodyNode.getBoundingClientRect();
- const blockEl = getDraggableBlockFromElement(target.domNode, this.pmView);
+ const blockEl = getBlockFromElement(target.domNode, this.pmView);
if (!blockEl) {
+ this.hideHandles();
return;
}
@@ -256,10 +257,7 @@ export class TableHandlesView implements PluginView {
doc: tr.doc,
}));
- // The hovered cell may belong to a document other than this editor's, as a
- // custom block can embed a nested editor which itself contains a table. The
- // nested editor's DOM is inside this view's DOM, so its cells still reach
- // this handler, but its block IDs are unknown here.
+ // The DOM target must still correspond to a block in this document.
if (!pmNodeInfo) {
this.hideHandles();
return;
diff --git a/packages/core/src/extensions/blockDOM.test.ts b/packages/core/src/extensions/blockDOM.test.ts
new file mode 100644
index 0000000000..27f6fa1de6
--- /dev/null
+++ b/packages/core/src/extensions/blockDOM.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ getBlockFromElement,
+ getDraggableBlockFromElement,
+} from "./blockDOM.js";
+
+function isDraggable(type: string) {
+ return type !== "lockedBlock" && type !== "column";
+}
+
+// Identity and ownership need detached DOM trees, not browser layout.
+
+/** Builds the `blockOuter > blockContainer > blockContent` chrome BlockNote
+ * renders around every regular block. */
+function regularBlock(
+ id: string,
+ contentType: string,
+): { outer: HTMLElement; blockContainer: HTMLElement; content: HTMLElement } {
+ const outer = document.createElement("div");
+ outer.setAttribute("data-node-type", "blockOuter");
+ outer.setAttribute("data-id", id);
+
+ const blockContainer = document.createElement("div");
+ blockContainer.setAttribute("data-node-type", "blockContainer");
+ blockContainer.setAttribute("data-id", id);
+
+ const content = document.createElement("div");
+ content.setAttribute("data-content-type", contentType);
+
+ blockContainer.append(content);
+ outer.append(blockContainer);
+ return { outer, blockContainer, content };
+}
+
+/** Nests `child` under `parent` in a `blockGroup`, as list nesting does. */
+function nest(parent: HTMLElement, child: HTMLElement) {
+ const group = document.createElement("div");
+ group.setAttribute("data-node-type", "blockGroup");
+ group.append(child);
+ parent.append(group);
+}
+
+function viewWith(root: HTMLElement) {
+ const dom = document.createElement("div");
+ dom.append(root);
+ return { dom };
+}
+
+describe("getDraggableBlockFromElement", () => {
+ it("returns the block container for a regular block", () => {
+ const { outer, blockContainer, content } = regularBlock("a", "paragraph");
+
+ expect(
+ getDraggableBlockFromElement(content, viewWith(outer), isDraggable),
+ ).toEqual({
+ node: blockContainer,
+ id: "a",
+ type: "paragraph",
+ });
+ });
+
+ it("resolves a locked block's identity but gives it no drag handle", () => {
+ const { outer, content, blockContainer } = regularBlock("a", "lockedBlock");
+
+ expect(getBlockFromElement(content, viewWith(outer))).toEqual({
+ node: blockContainer,
+ id: "a",
+ type: "lockedBlock",
+ });
+
+ expect(
+ getDraggableBlockFromElement(content, viewWith(outer), isDraggable),
+ ).toBeUndefined();
+ });
+
+ it("falls through to the nearest draggable ancestor", () => {
+ const parent = regularBlock("parent", "paragraph");
+ const child = regularBlock("child", "lockedBlock");
+ nest(parent.blockContainer, child.outer);
+
+ // Dragging from inside the locked child should hand back the parent's
+ // handle rather than no handle at all.
+ expect(
+ getDraggableBlockFromElement(
+ child.content,
+ viewWith(parent.outer),
+ isDraggable,
+ ),
+ ).toEqual({ node: parent.blockContainer, id: "parent", type: "paragraph" });
+ });
+
+ it("reads the block's own content type, not a nested block's", () => {
+ const parent = regularBlock("parent", "lockedBlock");
+ const child = regularBlock("child", "paragraph");
+ nest(parent.blockContainer, child.outer);
+
+ // `parent`'s own content element precedes the nested `blockGroup`, so the
+ // first `[data-content-type]` match inside it must be "lockedBlock".
+ expect(
+ getDraggableBlockFromElement(
+ parent.content,
+ viewWith(parent.outer),
+ isDraggable,
+ ),
+ ).toBeUndefined();
+ });
+
+ it("returns a container block only when its type is draggable", () => {
+ const column = document.createElement("div");
+ column.setAttribute("data-node-type", "column");
+ column.setAttribute("data-id", "col");
+
+ expect(
+ getDraggableBlockFromElement(column, viewWith(column), isDraggable),
+ ).toBeUndefined();
+
+ expect(
+ getDraggableBlockFromElement(column, viewWith(column), () => true),
+ ).toEqual({ node: column, id: "col", type: "column" });
+ });
+});
+
+it("does not resolve blocks outside this editor", () => {
+ const { content } = regularBlock("outside", "paragraph");
+ expect(
+ getBlockFromElement(content, viewWith(document.createElement("div"))),
+ ).toBeUndefined();
+});
+
+it("does not resolve a block owned by an embedded editor", () => {
+ const nested = regularBlock("nested", "paragraph");
+ const nestedView = viewWith(nested.outer);
+ nestedView.dom.className = "bn-editor";
+ const outerView = viewWith(nestedView.dom);
+ outerView.dom.className = "bn-editor";
+ expect(getBlockFromElement(nested.content, outerView)).toBeUndefined();
+ expect(getBlockFromElement(nested.content, nestedView)?.id).toBe("nested");
+});
diff --git a/packages/core/src/extensions/blockDOM.ts b/packages/core/src/extensions/blockDOM.ts
new file mode 100644
index 0000000000..bef930bda1
--- /dev/null
+++ b/packages/core/src/extensions/blockDOM.ts
@@ -0,0 +1,69 @@
+import type { EditorView } from "prosemirror-view";
+
+// UniqueID and the block renderers put data-id on every block root.
+// Ordinary blocks also copy it to blockOuter, which is only rendering chrome.
+export const BLOCK_SELECTOR =
+ '[data-node-type][data-id]:not([data-node-type="blockOuter"])';
+export const CONTAINER_SELECTOR = `${BLOCK_SELECTOR}:not([data-node-type="blockContainer"])`;
+
+/** Resolves block identity independently of whether it gets a drag handle. */
+export function getBlockFromElement(
+ element: Element,
+ view: Pick,
+): { node: HTMLElement; id: string; type: string } | undefined {
+ if (!view.dom.contains(element)) {
+ return undefined;
+ }
+ const node = element.closest(BLOCK_SELECTOR);
+ if (!node || node === view.dom || !view.dom.contains(node)) {
+ return undefined;
+ }
+
+ // Embedded editors own their blocks, even though their DOM is inside ours.
+ const owner = element.closest(".bn-editor");
+ if (owner && owner !== view.dom) {
+ return undefined;
+ }
+
+ const id = node.getAttribute("data-id");
+ const nodeType = node.getAttribute("data-node-type");
+ // The regular block's own content precedes its nested blockGroup, even
+ // when a renderFrame adds chrome around it.
+ const type =
+ nodeType === "blockContainer"
+ ? node
+ .querySelector("[data-content-type]")
+ ?.getAttribute("data-content-type")
+ : nodeType;
+ // A matched block root must expose its own identity. Validate this at the
+ // DOM boundary so callers never need casts or non-null assertions.
+ const HTMLElementClass = node.ownerDocument.defaultView?.HTMLElement;
+ if (
+ !HTMLElementClass ||
+ !(node instanceof HTMLElementClass) ||
+ !id ||
+ !type
+ ) {
+ throw new Error(
+ "Block root is missing its HTML element, ID, or block type.",
+ );
+ }
+ return { node, id, type };
+}
+
+/** A block that opts out hands its drag handle to the nearest eligible ancestor. */
+export function getDraggableBlockFromElement(
+ element: Element,
+ view: Pick,
+ isDraggable: (type: string) => boolean,
+) {
+ let block = getBlockFromElement(element, view);
+ while (block) {
+ if (isDraggable(block.type)) {
+ return block;
+ }
+ const parent = block.node.parentElement;
+ block = parent ? getBlockFromElement(parent, view) : undefined;
+ }
+ return undefined;
+}
diff --git a/packages/core/src/extensions/getDraggableBlockFromElement.ts b/packages/core/src/extensions/getDraggableBlockFromElement.ts
deleted file mode 100644
index abc6bd2906..0000000000
--- a/packages/core/src/extensions/getDraggableBlockFromElement.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { EditorView } from "prosemirror-view";
-
-export function getDraggableBlockFromElement(
- element: Element,
- view: EditorView,
-) {
- while (
- element &&
- element.parentElement &&
- element.parentElement !== view.dom &&
- element.getAttribute?.("data-node-type") !== "blockContainer"
- ) {
- element = element.parentElement;
- }
- if (element.getAttribute?.("data-node-type") !== "blockContainer") {
- return undefined;
- }
- return { node: element as HTMLElement, id: element.getAttribute("data-id")! };
-}
diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts
index 2f3464a2db..5e4723c847 100644
--- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts
+++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts
@@ -1,7 +1,10 @@
import { describe, expect, it } from "vite-plus/test";
import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js";
-import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js";
+import {
+ type PartialBlock,
+ defaultBlockSpecs,
+} from "../../../blocks/defaultBlocks.js";
import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
import { createBlockSpec } from "../../../schema/index.js";
@@ -548,3 +551,42 @@ describe("KeyboardShortcutsExtension hardBreakShortcut", () => {
editor._tiptapEditor.destroy();
});
});
+
+describe("Delete preserves the caret before appended text", () => {
+ function paragraph(id: string, children: PartialBlock[] = []): PartialBlock {
+ return { id, type: "paragraph", content: id, children };
+ }
+
+ it.each([
+ {
+ name: "sole child",
+ initialContent: [paragraph("selected", [paragraph("removed")])],
+ },
+ {
+ name: "following shallower block",
+ initialContent: [
+ paragraph("parent", [paragraph("selected")]),
+ paragraph("removed"),
+ ],
+ },
+ ])("$name", ({ initialContent }) => {
+ const editor = BlockNoteEditor.create({ initialContent });
+ editor.mount(document.createElement("div"));
+ editor.setTextCursorPosition("selected", "end");
+
+ const view = editor.prosemirrorView;
+ const event = new KeyboardEvent("keydown", {
+ key: "Delete",
+ code: "Delete",
+ keyCode: 46,
+ });
+ view.someProp("handleKeyDown", (handler) => handler(view, event));
+
+ expect(editor.getBlock("removed")).toBeUndefined();
+ expect(editor.getTextCursorPosition().block.id).toBe("selected");
+ expect(editor.prosemirrorState.selection.$from.parentOffset).toBe(
+ "selected".length,
+ );
+ editor._tiptapEditor.destroy();
+ });
+});
diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
index 20e5941fb6..56f2c043bd 100644
--- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
+++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
@@ -1,29 +1,79 @@
-import { Extension } from "@tiptap/core";
-import { Fragment, Node } from "prosemirror-model";
-import { TextSelection } from "prosemirror-state";
+import { type ChainedCommands, Extension } from "@tiptap/core";
+import { Fragment } from "prosemirror-model";
+import { TextSelection, Transaction } from "prosemirror-state";
-import { mergeBlocksCommand } from "../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js";
+import {
+ mergeBlocksCommand,
+ getMergeContent,
+} from "../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js";
import {
liftItem,
nestBlock,
unnestBlock,
} from "../../../api/blockManipulation/commands/nestBlock/nestBlock.js";
-import { fixColumnList } from "../../../api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js";
+import { fixContainersById } from "../../../api/blockManipulation/containers/fixContainer.js";
+import { isContainerNode } from "../../../schema/blocks/children.js";
import { splitBlockCommand } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js";
import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js";
import {
+ type BlockInfo,
+ ascendToInsertablePos,
+ getInsertionPos,
+ getAncestorContainers,
+ getFirstLeafBlock,
getBlockInfoAt,
+ getBlockInfoFromNode,
getBlockInfoFromSelection,
getLastDescendantBlockInfo,
getNextBlockInfo,
getParentBlockInfo,
getPrevBlockInfo,
- tableContentCaretPos,
+ blockEdgeSelection,
} from "../../../api/getBlockInfoFromPos.js";
import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
import { FilePanelExtension } from "../../FilePanel/FilePanel.js";
import { FormattingToolbarExtension } from "../../FormattingToolbar/FormattingToolbar.js";
+// Move a block across a container boundary, repair its former ancestors, and
+// map the caret through any repairs that change the insertion position.
+function moveBlockOutAndPlaceCaret(
+ tr: Transaction,
+ block: BlockInfo["block"],
+ insertAt: number,
+) {
+ const containersToFix = getAncestorContainers(tr.doc, block.beforePos);
+ tr.delete(block.beforePos, block.afterPos);
+ const insertionPos = tr.mapping.map(insertAt);
+ tr.insert(insertionPos, block.node);
+ const stepsBeforeFix = tr.steps.length;
+ fixContainersById(tr, containersToFix);
+ tr.setSelection(
+ TextSelection.near(
+ tr.doc.resolve(tr.mapping.slice(stepsBeforeFix).map(insertionPos) + 1),
+ ),
+ );
+}
+
+// Delete a following block, retaining its children and any compatible text.
+// A sole child also removes its child group instead of leaving an empty body.
+function deleteBlockAndAppendContent(
+ chain: ChainedCommands,
+ current: Extract,
+ next: Extract,
+ remove: Pick = next.block,
+) {
+ return chain
+ .insertContentAt(
+ next.block.afterPos,
+ next.children?.node.content || Fragment.empty,
+ )
+ .deleteRange({ from: remove.beforePos, to: remove.afterPos })
+ .insertContentAt(current.contentEnd, getMergeContent(current, next) ?? null)
+ .setTextSelection(current.contentEnd)
+ .scrollIntoView()
+ .run();
+}
+
export const KeyboardShortcutsExtension = Extension.create<{
editor: BlockNoteEditor;
tabBehavior: "prefer-navigate-ui" | "prefer-indent";
@@ -49,7 +99,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
}
const selectionAtBlockStart =
- state.selection.from === blockInfo.content.beforePos + 1;
+ state.selection.from === blockInfo.contentStart;
const isParagraph =
blockInfo.content.node.type.name === "paragraph";
@@ -71,10 +121,9 @@ export const KeyboardShortcutsExtension = Extension.create<{
if (!blockInfo.hasContent) {
return false;
}
- const { content } = blockInfo;
const selectionAtBlockStart =
- state.selection.from === content.beforePos + 1;
+ state.selection.from === blockInfo.contentStart;
if (selectionAtBlockStart) {
return liftItem(
@@ -94,28 +143,28 @@ export const KeyboardShortcutsExtension = Extension.create<{
if (!blockInfo.hasContent) {
return false;
}
- const { block, content } = blockInfo;
+ const { block: blockContainer } = blockInfo;
- const prevBlockInfo = getPrevBlockInfo(
+ const prevSibling = getPrevBlockInfo(
state.doc,
blockInfo.block.beforePos,
);
- // If the previous block has no inline content, it can't be merged.
- // It's instead deleted, which is done later in the chan, so we
- // return early here.
+ // A preceding container or owned body takes the move branch below.
+ // With no sibling, mergeBlocksCommand checks for an owning title.
if (
- !prevBlockInfo ||
- !prevBlockInfo.hasContent ||
- prevBlockInfo.contentKind !== "inline"
+ prevSibling &&
+ (!prevSibling.hasContent ||
+ prevSibling.contentKind !== "inline" ||
+ (prevSibling.children && prevSibling.hasOwnedChildren))
) {
return false;
}
const selectionAtBlockStart =
- state.selection.from === content.beforePos + 1;
+ state.selection.from === blockInfo.contentStart;
const selectionEmpty = state.selection.empty;
- const posBetweenBlocks = block.beforePos;
+ const posBetweenBlocks = blockContainer.beforePos;
if (selectionAtBlockStart && selectionEmpty) {
return chain()
@@ -126,93 +175,68 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
- // If the previous block is a columnList, moves the current block to
- // the end of the last column in it.
- () =>
- commands.command(({ state, tr, dispatch }) => {
- const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.hasContent) {
- return false;
- }
-
- const selectionAtBlockStart =
- state.selection.from === blockInfo.content.beforePos + 1;
- if (!selectionAtBlockStart) {
- return false;
- }
-
- const prevBlockInfo = getPrevBlockInfo(
- state.doc,
- blockInfo.block.beforePos,
- );
- if (!prevBlockInfo || prevBlockInfo.hasContent) {
- return false;
- }
-
- if (dispatch) {
- const columnAfterPos = prevBlockInfo.block.afterPos - 1;
- const $blockAfterPos = tr.doc.resolve(columnAfterPos - 1);
-
- tr.delete(blockInfo.block.beforePos, blockInfo.block.afterPos);
- tr.insert($blockAfterPos.pos, blockInfo.block.node);
- tr.setSelection(
- TextSelection.near(tr.doc.resolve($blockAfterPos.pos + 1)),
- );
-
- return true;
- }
-
- return false;
- }),
- // If the block is the first in a column, moves it to the end of the
- // previous column. If there is no previous column, moves it above the
- // columnList.
+ // Move into the preceding container's trailing slot, or out of the
+ // current container when this is its first block.
() =>
commands.command(({ state, tr, dispatch }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.hasContent) {
+ if (
+ !blockInfo.hasContent ||
+ state.selection.from !== blockInfo.contentStart
+ ) {
return false;
}
- const selectionAtBlockStart =
- tr.selection.from === blockInfo.content.beforePos + 1;
- if (!selectionAtBlockStart) {
- return false;
+ const blockType = blockInfo.block.node.type;
+ let target = getPrevBlockInfo(tr.doc, blockInfo.block.beforePos);
+ let insertionPos: number | undefined;
+ if (target) {
+ if (
+ target.hasContent &&
+ !(target.children && target.hasOwnedChildren)
+ ) {
+ return false;
+ }
+ } else {
+ const $pos = tr.doc.resolve(blockInfo.block.beforePos);
+ if (!isContainerNode($pos.parent.type)) {
+ return false;
+ }
+ const $containerPos = tr.doc.resolve($pos.before());
+ // Between columns, move into the previous column. Outside a
+ // container, move above the closest boundary that accepts us.
+ const prevSibling = $containerPos.nodeBefore;
+ if (
+ isContainerNode($containerPos.parent.type) &&
+ prevSibling &&
+ isContainerNode(prevSibling.type)
+ ) {
+ target = getBlockInfoFromNode(
+ prevSibling,
+ $containerPos.pos - prevSibling.nodeSize,
+ );
+ } else {
+ insertionPos = ascendToInsertablePos(
+ tr.doc,
+ $containerPos.pos,
+ blockType,
+ );
+ }
}
-
- const $pos = tr.doc.resolve(blockInfo.block.beforePos);
-
- const prevBlock = $pos.nodeBefore;
- if (prevBlock) {
- return false;
+ if (target) {
+ insertionPos = getInsertionPos(
+ tr.doc,
+ target,
+ "last-child",
+ blockType,
+ )?.pos;
}
-
- const parentBlock = $pos.node();
- if (parentBlock.type.name !== "column") {
+ if (insertionPos === undefined) {
return false;
}
-
- const $blockPos = tr.doc.resolve(blockInfo.block.beforePos);
- const $columnPos = tr.doc.resolve($blockPos.before());
- const columnListPos = $columnPos.before();
-
if (dispatch) {
- tr.delete(blockInfo.block.beforePos, blockInfo.block.afterPos);
- fixColumnList(tr, columnListPos);
-
- if ($columnPos.pos === columnListPos + 1) {
- tr.insert(columnListPos, blockInfo.block.node);
- tr.setSelection(
- TextSelection.near(tr.doc.resolve(columnListPos)),
- );
- } else {
- tr.insert($columnPos.pos - 1, blockInfo.block.node);
- tr.setSelection(
- TextSelection.near(tr.doc.resolve($columnPos.pos)),
- );
- }
+ moveBlockOutAndPlaceCaret(tr, blockInfo.block, insertionPos);
}
-
return true;
}),
// Deletes the current block if it's an empty block with inline content,
@@ -225,8 +249,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
}
const blockEmpty =
- blockInfo.content.node.childCount === 0 &&
- blockInfo.contentKind === "inline";
+ blockInfo.isContentEmpty && blockInfo.contentKind === "inline";
if (blockEmpty) {
const prevBlockInfo = getPrevBlockInfo(
@@ -241,14 +264,8 @@ export const KeyboardShortcutsExtension = Extension.create<{
if (!bottomNestedPrevBlockInfo.hasContent) {
return false;
}
- if (
- !bottomNestedPrevBlockInfo ||
- !bottomNestedPrevBlockInfo.hasContent
- ) {
- return false;
- }
- let chainedCommands = chain();
+ const chainedCommands = chain();
// Moves the children the current block.
if (blockInfo.children) {
@@ -258,24 +275,12 @@ export const KeyboardShortcutsExtension = Extension.create<{
);
}
- if (bottomNestedPrevBlockInfo.contentKind === "table") {
- chainedCommands = chainedCommands.setTextSelection(
- tableContentCaretPos(
- bottomNestedPrevBlockInfo.content,
- "end",
- ),
- );
- } else if (bottomNestedPrevBlockInfo.contentKind === "none") {
- chainedCommands = chainedCommands.setNodeSelection(
- bottomNestedPrevBlockInfo.content.beforePos,
+ chainedCommands.command(({ tr }) => {
+ tr.setSelection(
+ blockEdgeSelection(tr.doc, bottomNestedPrevBlockInfo, "end"),
);
- } else {
- const contentEndPos =
- bottomNestedPrevBlockInfo.content.afterPos - 1;
-
- chainedCommands =
- chainedCommands.setTextSelection(contentEndPos);
- }
+ return true;
+ });
return chainedCommands
.deleteRange({
@@ -300,7 +305,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
}
const selectionAtBlockStart =
- state.selection.from === blockInfo.content.beforePos + 1;
+ state.selection.from === blockInfo.contentStart;
const selectionEmpty = state.selection.empty;
const prevBlockInfo = getPrevBlockInfo(
@@ -309,6 +314,9 @@ export const KeyboardShortcutsExtension = Extension.create<{
);
if (prevBlockInfo && selectionAtBlockStart && selectionEmpty) {
+ // An emptied container has no content to merge with, so the
+ // guard below rejects it — the merge branch above only fires
+ // for a previous block with content of its own.
const bottomBlock = getLastDescendantBlockInfo(prevBlockInfo);
if (!bottomBlock.hasContent) {
@@ -356,56 +364,34 @@ export const KeyboardShortcutsExtension = Extension.create<{
if (!blockInfo.hasContent || !blockInfo.children) {
return false;
}
- const { content, children } = blockInfo;
+ const { children } = blockInfo;
+
+ // A container allowed to hold no children still has a child
+ // container node, but no first child to pull anything out of.
+ if (children.node.childCount === 0) {
+ return false;
+ }
const selectionAtBlockEnd =
- state.selection.from === content.afterPos - 1;
+ state.selection.from === blockInfo.contentEnd;
const selectionEmpty = state.selection.empty;
const firstChildBlockInfo = getBlockInfoAt(
state.doc,
- children.beforePos + 1,
+ children.childrenStart,
);
if (!firstChildBlockInfo.hasContent) {
return false;
}
if (selectionAtBlockEnd && selectionEmpty) {
- const firstChildBlockContent = firstChildBlockInfo.content.node;
- const firstChildBlockHasInlineContent =
- firstChildBlockInfo.contentKind === "inline";
- const blockHasInlineContent = blockInfo.contentKind === "inline";
-
- return (
- chain()
- // Un-nests child block's children if necessary.
- .insertContentAt(
- firstChildBlockInfo.block.afterPos,
- firstChildBlockInfo.children?.node.content ||
- Fragment.empty,
- )
- .deleteRange(
- // Deletes whole child container if there's only one child.
- children.node.childCount === 1
- ? {
- from: children.beforePos,
- to: children.afterPos,
- }
- : {
- from: firstChildBlockInfo.block.beforePos,
- to: firstChildBlockInfo.block.afterPos,
- },
- )
- // Appends inline content from child block if possible.
- .insertContentAt(
- state.selection.from,
- firstChildBlockHasInlineContent && blockHasInlineContent
- ? firstChildBlockContent.content
- : null,
- )
- .setTextSelection(state.selection.from)
- .scrollIntoView()
- .run()
+ return deleteBlockAndAppendContent(
+ chain(),
+ blockInfo,
+ firstChildBlockInfo,
+ children.node.childCount === 1
+ ? children
+ : firstChildBlockInfo.block,
);
}
@@ -420,7 +406,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
if (!blockInfo.hasContent) {
return false;
}
- const { block, content } = blockInfo;
+ const { block: blockContainer } = blockInfo;
const nextBlockInfo = getNextBlockInfo(
state.doc,
@@ -431,10 +417,10 @@ export const KeyboardShortcutsExtension = Extension.create<{
}
const selectionAtBlockEnd =
- state.selection.from === content.afterPos - 1;
+ state.selection.from === blockInfo.contentEnd;
const selectionEmpty = state.selection.empty;
- const posBetweenBlocks = block.afterPos;
+ const posBetweenBlocks = blockContainer.afterPos;
if (selectionAtBlockEnd && selectionEmpty) {
return chain()
@@ -445,103 +431,49 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
- // If the next block is a columnList, moves the first block from its
- // first column to after the current block.
+ // Pull the next leaf across a container boundary. It may be inside
+ // the next sibling container, or follow the containers we're leaving.
() =>
commands.command(({ state, tr, dispatch }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.hasContent) {
- return false;
- }
-
- const selectionAtBlockEnd =
- state.selection.from === blockInfo.content.afterPos - 1;
- if (!selectionAtBlockEnd) {
- return false;
- }
-
- const nextBlockInfo = getNextBlockInfo(
- state.doc,
- blockInfo.block.beforePos,
- );
- if (!nextBlockInfo || nextBlockInfo.hasContent) {
+ if (
+ !blockInfo.hasContent ||
+ state.selection.from !== blockInfo.contentEnd
+ ) {
return false;
}
- if (dispatch) {
- const columnBeforePos = nextBlockInfo.block.beforePos + 1;
- const $blockBeforePos = tr.doc.resolve(columnBeforePos + 1);
-
- tr.delete(
- $blockBeforePos.pos,
- $blockBeforePos.pos + $blockBeforePos.nodeAfter!.nodeSize,
- );
- fixColumnList(tr, nextBlockInfo.block.beforePos);
- tr.insert(blockInfo.block.afterPos, $blockBeforePos.nodeAfter!);
- tr.setSelection(
- TextSelection.near(tr.doc.resolve($blockBeforePos.pos)),
- );
-
- return true;
- }
-
- return false;
- }),
- // If the block is the last in a column, moves it to the start of the
- // next column. If there is no next column, moves it below the
- // columnList.
- () =>
- commands.command(({ state, tr, dispatch }) => {
- const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.hasContent) {
- return false;
+ let $boundary = tr.doc.resolve(blockInfo.block.afterPos);
+ while (
+ !$boundary.nodeAfter &&
+ $boundary.depth > 0 &&
+ isContainerNode($boundary.parent.type)
+ ) {
+ $boundary = tr.doc.resolve($boundary.after());
}
-
- const selectionAtBlockEnd =
- tr.selection.from === blockInfo.content.afterPos - 1;
- if (!selectionAtBlockEnd) {
+ const nextNode = $boundary.nodeAfter;
+ if (!nextNode) {
return false;
}
- const $pos = tr.doc.resolve(blockInfo.block.afterPos);
-
- const nextBlock = $pos.nodeAfter;
- if (nextBlock) {
+ const crossedBoundary = $boundary.pos !== blockInfo.block.afterPos;
+ if (!crossedBoundary && !isContainerNode(nextNode.type)) {
return false;
}
-
- const parentBlock = $pos.node();
- if (parentBlock.type.name !== "column") {
+ const target = getFirstLeafBlock(
+ getBlockInfoFromNode(nextNode, $boundary.pos),
+ );
+ if (!target) {
return false;
}
- const $blockEndPos = tr.doc.resolve(blockInfo.block.afterPos);
- const $columnEndPos = tr.doc.resolve($blockEndPos.after());
- const columnListEndPos = $columnEndPos.after();
-
if (dispatch) {
- // Position before first block in next column, or first block
- // after columnList if there is no next column.
- const nextBlockBeforePos =
- $columnEndPos.pos === columnListEndPos - 1
- ? columnListEndPos
- : $columnEndPos.pos + 1;
- const nextBlockInfo = getBlockInfoAt(tr.doc, nextBlockBeforePos);
-
- tr.delete(
- nextBlockInfo.block.beforePos,
- nextBlockInfo.block.afterPos,
- );
- fixColumnList(
+ moveBlockOutAndPlaceCaret(
tr,
- columnListEndPos - $columnEndPos.node().nodeSize,
- );
- tr.insert($blockEndPos.pos, nextBlockInfo.block.node);
- tr.setSelection(
- TextSelection.near(tr.doc.resolve(nextBlockBeforePos)),
+ target.block,
+ blockInfo.block.afterPos,
);
}
-
return true;
}),
// Deletes the next block at either the same or lower nesting level, if
@@ -555,66 +487,35 @@ export const KeyboardShortcutsExtension = Extension.create<{
if (!blockInfo.hasContent) {
return false;
}
- const { content } = blockInfo;
const selectionAtBlockEnd =
- state.selection.from === content.afterPos - 1;
+ state.selection.from === blockInfo.contentEnd;
const selectionEmpty = state.selection.empty;
if (selectionAtBlockEnd && selectionEmpty) {
- const getNextBlockInfoAtAnyLevel = (
- doc: Node,
- beforePos: number,
- ) => {
- const nextBlockInfo = getNextBlockInfo(doc, beforePos);
+ let nextBlockInfo: BlockInfo | undefined;
+ let ancestor: BlockInfo | undefined = blockInfo;
+ while (ancestor) {
+ nextBlockInfo = getNextBlockInfo(
+ state.doc,
+ ancestor.block.beforePos,
+ );
if (nextBlockInfo) {
- return nextBlockInfo;
- }
-
- const parentBlockInfo = getParentBlockInfo(doc, beforePos);
- if (!parentBlockInfo) {
- return undefined;
+ break;
}
-
- return getNextBlockInfoAtAnyLevel(
- doc,
- parentBlockInfo.block.beforePos,
+ ancestor = getParentBlockInfo(
+ state.doc,
+ ancestor.block.beforePos,
);
- };
-
- const nextBlockInfo = getNextBlockInfoAtAnyLevel(
- state.doc,
- blockInfo.block.beforePos,
- );
+ }
if (!nextBlockInfo || !nextBlockInfo.hasContent) {
return false;
}
- const nextBlockHasInlineContent =
- nextBlockInfo.contentKind === "inline";
- const blockHasInlineContent = blockInfo.contentKind === "inline";
-
- return (
- chain()
- // Un-nests next block's children if necessary.
- .insertContentAt(
- nextBlockInfo.block.afterPos,
- nextBlockInfo.children?.node.content || Fragment.empty,
- )
- .deleteRange({
- from: nextBlockInfo.block.beforePos,
- to: nextBlockInfo.block.afterPos,
- })
- // Appends inline content from child block if possible.
- .insertContentAt(
- state.selection.from,
- nextBlockHasInlineContent && blockHasInlineContent
- ? nextBlockInfo.content.node.content
- : null,
- )
- .setTextSelection(state.selection.from)
- .scrollIntoView()
- .run()
+ return deleteBlockAndAppendContent(
+ chain(),
+ blockInfo,
+ nextBlockInfo,
);
}
@@ -630,8 +531,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
}
const blockEmpty =
- blockInfo.content.node.childCount === 0 &&
- blockInfo.contentKind === "inline";
+ blockInfo.isContentEmpty && blockInfo.contentKind === "inline";
if (blockEmpty) {
const nextBlockInfo = getNextBlockInfo(
@@ -642,21 +542,14 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}
- let chainedCommands = chain();
+ const chainedCommands = chain();
- if (nextBlockInfo.contentKind === "table") {
- chainedCommands = chainedCommands.setTextSelection(
- tableContentCaretPos(nextBlockInfo.content, "start"),
- );
- } else if (nextBlockInfo.contentKind === "none") {
- chainedCommands = chainedCommands.setNodeSelection(
- nextBlockInfo.content.beforePos,
- );
- } else {
- chainedCommands = chainedCommands.setTextSelection(
- nextBlockInfo.content.beforePos + 1,
+ chainedCommands.command(({ tr }) => {
+ tr.setSelection(
+ blockEdgeSelection(tr.doc, nextBlockInfo, "start"),
);
- }
+ return true;
+ });
return chainedCommands
.deleteRange({
@@ -681,7 +574,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
}
const selectionAtBlockEnd =
- state.selection.from === blockInfo.content.afterPos - 1;
+ state.selection.from === blockInfo.contentEnd;
const selectionEmpty = state.selection.empty;
const nextBlockInfo = getNextBlockInfo(
@@ -702,7 +595,6 @@ export const KeyboardShortcutsExtension = Extension.create<{
nextBlockInfo.isContentEmpty);
if (nextBlockNotTableAndNoContent) {
- const childBlocks = nextBlockInfo.block.node.lastChild!.content;
return chain()
.deleteRange({
from: nextBlockInfo.block.beforePos,
@@ -710,9 +602,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
})
.insertContentAt(
blockInfo.block.afterPos,
- nextBlockInfo.block.node.childCount === 2
- ? childBlocks
- : null,
+ nextBlockInfo.children?.node.content ?? null,
)
.run();
}
@@ -732,15 +622,15 @@ export const KeyboardShortcutsExtension = Extension.create<{
if (!blockInfo.hasContent) {
return false;
}
- const { block, content } = blockInfo;
+ const { block: blockContainer } = blockInfo;
- const { depth } = state.doc.resolve(block.beforePos);
+ const { depth } = state.doc.resolve(blockContainer.beforePos);
const selectionAtBlockStart =
state.selection.$anchor.parentOffset === 0;
const selectionEmpty =
state.selection.anchor === state.selection.head;
- const blockEmpty = content.node.childCount === 0;
+ const blockEmpty = blockInfo.isContentEmpty;
const blockIndented = depth > 1;
if (
@@ -766,14 +656,6 @@ export const KeyboardShortcutsExtension = Extension.create<{
const blockSpec =
this.options.editor.schema.blockSpecs[blockInfo.blockNoteType];
- // NOTE: This likely doesn't work as intended - `blockSchema[type]`
- // holds the block *config* (type/propSchema/content), which carries
- // no `meta`, so `meta?.hardBreakShortcut` is always `undefined` and
- // this falls back to the default. It should read from the block
- // spec's implementation instead (i.e.
- // `editor.schema.blockSpecs[type].implementation.meta`), the way the
- // syntax-highlighting extension reads `meta.highlight`. Left as-is
- // for a follow-up pass.
const blockHardBreakShortcut =
blockSpec?.implementation?.meta?.hardBreakShortcut ??
"shift+enter";
@@ -818,6 +700,63 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
+ // If the block is empty and the last child of a container or an
+ // owned-children body, moves the block out (double Enter exits the
+ // container). The block lands at the nearest enclosing position that
+ // accepts it. E.g. out of a column it skips the columnList, which
+ // holds only columns, and lands below it. Without this, Enter only
+ // ever creates new blocks within the container, so the cursor could
+ // never leave a trailing container. Shift+Enter still adds spacing
+ // inside a container. The first block of a body stays put: it is
+ // where the body begins, not a way out of it.
+ () =>
+ commands.command(({ state, tr, dispatch }) => {
+ const blockInfo = getBlockInfoFromSelection(state);
+ if (!blockInfo.hasContent) {
+ return false;
+ }
+
+ const selectionEmpty =
+ state.selection.anchor === state.selection.head;
+ const blockEmpty = blockInfo.isContentEmpty;
+ if (!selectionEmpty || !blockEmpty) {
+ return false;
+ }
+
+ const $pos = tr.doc.resolve(blockInfo.block.beforePos);
+ // Only fires on the container's last child.
+ if (tr.doc.resolve(blockInfo.block.afterPos).nodeAfter !== null) {
+ return false;
+ }
+
+ const owner = getParentBlockInfo(tr.doc, blockInfo.block.beforePos);
+ if (!owner || !owner.hasOwnedChildren) {
+ return false;
+ }
+ // The first block of a body stays put: it is where the body
+ // begins, not a way out of it. (A container's own first child has
+ // no such role, so it may still leave.)
+ if ($pos.index() === 0 && owner.hasContent) {
+ return false;
+ }
+
+ const ownerAfterPos = ascendToInsertablePos(
+ tr.doc,
+ owner.block.afterPos,
+ state.schema.nodes["blockContainer"],
+ "after",
+ );
+ if (ownerAfterPos === undefined) {
+ return false;
+ }
+
+ if (dispatch) {
+ moveBlockOutAndPlaceCaret(tr, blockInfo.block, ownerAfterPos);
+ tr.scrollIntoView();
+ }
+
+ return true;
+ }),
// Creates a new block and moves the selection to it if the current one is empty, while the selection is also
// empty & at the start of the block.
() =>
@@ -826,16 +765,21 @@ export const KeyboardShortcutsExtension = Extension.create<{
if (!blockInfo.hasContent) {
return false;
}
- const { block, content } = blockInfo;
+ const { block: blockContainer } = blockInfo;
const selectionAtBlockStart =
state.selection.$anchor.parentOffset === 0;
const selectionEmpty =
state.selection.anchor === state.selection.head;
- const blockEmpty = content.node.childCount === 0;
+ const blockEmpty = blockInfo.isContentEmpty;
- if (selectionAtBlockStart && selectionEmpty && blockEmpty) {
- const newBlockInsertionPos = block.afterPos;
+ if (
+ selectionAtBlockStart &&
+ selectionEmpty &&
+ blockEmpty &&
+ !blockInfo.hasOwnedChildren
+ ) {
+ const newBlockInsertionPos = blockContainer.afterPos;
const newBlockContentPos = newBlockInsertionPos + 2;
if (dispatch) {
@@ -874,6 +818,69 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
+ // Enter in a titled block's own content (a callout's title) starts its
+ // body rather than splitting the block in two: whatever follows the
+ // cursor becomes the body's first block, and the body the callout
+ // already had stays where it is.
+ () =>
+ commands.command(({ state, tr, dispatch }) => {
+ const blockInfo = getBlockInfoFromSelection(state);
+ if (!blockInfo.hasContent) {
+ return false;
+ }
+
+ if (!blockInfo.hasOwnedChildren) {
+ return false;
+ }
+ if (!state.selection.empty) {
+ return false;
+ }
+ if (
+ state.selection.from < blockInfo.contentStart ||
+ state.selection.from > blockInfo.contentEnd
+ ) {
+ return false;
+ }
+
+ if (dispatch) {
+ // Everything after the cursor moves into the new block, so
+ // splitting the title mid-way puts its tail at the top of the
+ // body instead of handing the body to a new sibling.
+ const tail = blockInfo.content.node.cut(
+ state.selection.from - blockInfo.contentStart,
+ );
+ const newBlock = state.schema.nodes["blockContainer"].create(
+ undefined,
+ state.schema.nodes["paragraph"].create(undefined, tail.content),
+ );
+
+ tr.delete(state.selection.from, blockInfo.contentEnd);
+
+ const body = getBlockInfoAt(
+ tr.doc,
+ blockInfo.block.beforePos,
+ ).children;
+ // Without a body yet, one is created around the new block.
+ const insertPos = body
+ ? body.childrenStart
+ : tr.mapping.map(blockInfo.content.afterPos);
+ tr.insert(
+ insertPos,
+ body
+ ? newBlock
+ : state.schema.nodes["blockGroup"].create(
+ undefined,
+ newBlock,
+ ),
+ )
+ .setSelection(
+ new TextSelection(tr.doc.resolve(insertPos + (body ? 2 : 3))),
+ )
+ .scrollIntoView();
+ }
+
+ return true;
+ }),
// Splits the current block, moving content inside that's after the cursor to a new text block below. Also
// deletes the selection beforehand, if it's not empty.
() =>
@@ -882,11 +889,10 @@ export const KeyboardShortcutsExtension = Extension.create<{
if (!blockInfo.hasContent) {
return false;
}
- const { content } = blockInfo;
const selectionAtBlockStart =
state.selection.$anchor.parentOffset === 0;
- const blockEmpty = content.node.childCount === 0;
+ const blockEmpty = blockInfo.isContentEmpty;
if (!blockEmpty) {
chain()
@@ -909,42 +915,27 @@ export const KeyboardShortcutsExtension = Extension.create<{
]);
};
+ const options = this.options;
+ function handleTab(shift: boolean) {
+ const { editor, tabBehavior } = options;
+ if (
+ tabBehavior !== "prefer-indent" &&
+ (editor.getExtension(FormattingToolbarExtension)?.store.state ||
+ editor.getExtension(FilePanelExtension)?.store.state !== undefined)
+ ) {
+ // Let the browser navigate into and out of an open toolbar.
+ return false;
+ }
+ return shift ? unnestBlock(editor) : nestBlock(editor);
+ }
+
return {
Backspace: handleBackspace,
Delete: handleDelete,
Enter: () => handleEnter(),
"Shift-Enter": () => handleEnter(true),
- // Always returning true for tab key presses ensures they're not captured by the browser. Otherwise, they blur the
- // editor since the browser will try to use tab for keyboard navigation.
- Tab: () => {
- if (
- this.options.tabBehavior !== "prefer-indent" &&
- (this.options.editor.getExtension(FormattingToolbarExtension)?.store
- .state ||
- this.options.editor.getExtension(FilePanelExtension)?.store
- .state !== undefined)
- // TODO need to check if the link toolbar is open or another alternative entirely
- ) {
- // don't handle tabs if a toolbar is shown, so we can tab into / out of it
- return false;
- }
- return nestBlock(this.options.editor);
- },
- "Shift-Tab": () => {
- if (
- this.options.tabBehavior !== "prefer-indent" &&
- (this.options.editor.getExtension(FormattingToolbarExtension)?.store
- .state ||
- this.options.editor.getExtension(FilePanelExtension)?.store
- .state !== undefined)
- // TODO need to check if the link toolbar is open or another alternative entirely
- // other menu types?
- ) {
- // don't handle tabs if a toolbar is shown, so we can tab into / out of it
- return false;
- }
- return unnestBlock(this.options.editor);
- },
+ Tab: () => handleTab(false),
+ "Shift-Tab": () => handleTab(true),
"Shift-Mod-ArrowUp": () => {
this.options.editor.moveBlocksUp();
return true;
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index b4f220e1e2..be0fac887b 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,6 +1,14 @@
export * from "./api/blockManipulation/commands/insertBlocks/insertBlocks.js";
export * from "./api/blockManipulation/commands/replaceBlocks/replaceBlocks.js";
-export * from "./api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js";
+export {
+ isContainerNode,
+ isContainerConfig,
+} from "./schema/blocks/children.js";
+export { applyContainerAttributes } from "./schema/blocks/containerAttributes.js";
+export {
+ fixContainer,
+ isEmptyContainerChild,
+} from "./api/blockManipulation/containers/fixContainer.js";
export * from "./api/blockManipulation/commands/updateBlock/updateBlock.js";
export * from "./api/exporters/html/externalHTMLExporter.js";
export * from "./api/exporters/html/internalHTMLSerializer.js";
diff --git a/packages/core/src/pm-nodes/BlockContainer.ts b/packages/core/src/pm-nodes/BlockContainer.ts
index 86bd2ccb15..87da0dffed 100644
--- a/packages/core/src/pm-nodes/BlockContainer.ts
+++ b/packages/core/src/pm-nodes/BlockContainer.ts
@@ -1,10 +1,67 @@
-import { Node } from "@tiptap/core";
+import {
+ Node,
+ type NodeViewRenderer,
+ type NodeViewRendererProps,
+} from "@tiptap/core";
+import type { NodeView } from "@tiptap/pm/view";
import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
+import { nodeToBlock } from "../api/nodeConversions/nodeToBlock.js";
+import { isDocumentFragment } from "../schema/blocks/internal.js";
import { BlockNoteDOMAttributes } from "../schema/index.js";
+import { ignoreNonContentMutations } from "../schema/nodeViewMutations.js";
import { mergeCSSClasses } from "../util/browser.js";
import { suggestionMarks } from "./suggestionMarks.js";
+/** Adapts vanilla frames to the same lifecycle as framework node views. */
+function createFrameView(
+ props: NodeViewRendererProps,
+ editor: BlockNoteEditor,
+ fallback: HTMLElement,
+ blockContentDOMAttributes: Record,
+): NodeView {
+ const type = props.node.firstChild!.type.name;
+ const implementation = editor.blockImplementations[type].implementation;
+ if (implementation.frameNodeView) {
+ return implementation.frameNodeView(props);
+ }
+
+ const renderFrame = implementation.renderFrame<
+ typeof editor.schema.inlineContentSchema,
+ typeof editor.schema.styleSchema
+ >;
+ const frame = renderFrame?.call(
+ {
+ renderType: "nodeView",
+ props,
+ blockContentDOMAttributes,
+ propSchema: editor.blockImplementations[type].config.propSchema,
+ },
+ nodeToBlock(props.node, props.view.state.doc),
+ editor,
+ );
+ let dom = frame?.dom ?? fallback;
+ if (isDocumentFragment(dom)) {
+ // Node views need a stable element even when the author returns siblings.
+ const wrapper = document.createElement("div");
+ wrapper.style.display = "contents";
+ wrapper.append(dom);
+ dom = wrapper;
+ }
+ return {
+ dom,
+ contentDOM: frame?.slot ?? fallback,
+ destroy: frame?.destroy?.bind(frame),
+ update(node) {
+ if (frame?.update) {
+ return frame.update(nodeToBlock(node, props.view.state.doc)) !== false;
+ }
+ // Declined frames must also be reconsidered when their block changes.
+ return !renderFrame || node.eq(props.node);
+ },
+ };
+}
+
// Object containing all possible block attributes.
const BlockAttributes: Record = {
blockColor: "data-block-color",
@@ -88,4 +145,57 @@ export const BlockContainer = Node.create<{
contentDOM: block,
};
},
+
+ addNodeView() {
+ // Cast: this returns a plain ProseMirror node view, which tiptap's
+ // `NodeViewRenderer` type doesn't model.
+ return ((props: NodeViewRendererProps) => {
+ const editor = this.options.editor;
+ const { dom, contentDOM } = this.type.spec.toDOM!(props.node) as {
+ dom: HTMLElement;
+ contentDOM: HTMLElement;
+ };
+ const frameView = createFrameView(
+ props,
+ editor,
+ contentDOM,
+ this.options.domAttributes?.blockContent || {},
+ );
+ const framed = frameView.dom !== contentDOM;
+ if (framed) {
+ contentDOM.appendChild(frameView.dom);
+ }
+
+ const nodeView: NodeView = {
+ dom,
+ contentDOM: frameView.contentDOM ?? contentDOM,
+ update(node, decorations, innerDecorations) {
+ // Changing the wrapper or block type replaces the complete view.
+ return (
+ node.sameMarkup(props.node) &&
+ node.firstChild?.type === props.node.firstChild?.type &&
+ (frameView.update?.(node, decorations, innerDecorations) ?? false)
+ );
+ },
+ stopEvent(event) {
+ // Author chrome handles its own events; the slot remains editable.
+ const target = event.target;
+ return (
+ (target instanceof globalThis.Node &&
+ frameView.dom.contains(target) &&
+ !nodeView.contentDOM?.contains(target)) ||
+ (frameView.stopEvent?.(event) ?? false)
+ );
+ },
+ destroy: frameView.destroy?.bind(frameView),
+ selectNode: frameView.selectNode?.bind(frameView),
+ deselectNode: frameView.deselectNode?.bind(frameView),
+ ignoreMutation: frameView.ignoreMutation?.bind(frameView),
+ };
+ if (framed) {
+ ignoreNonContentMutations(nodeView);
+ }
+ return nodeView;
+ }) as unknown as NodeViewRenderer;
+ },
});
diff --git a/packages/core/src/pm-nodes/BlockGroup.ts b/packages/core/src/pm-nodes/BlockGroup.ts
index 9fe644a5db..bba1d84f4d 100644
--- a/packages/core/src/pm-nodes/BlockGroup.ts
+++ b/packages/core/src/pm-nodes/BlockGroup.ts
@@ -1,4 +1,5 @@
import { Node } from "@tiptap/core";
+import { CHILD_CONTAINER_GROUP } from "../schema/blocks/children.js";
import { BlockNoteDOMAttributes } from "../schema/index.js";
import { mergeCSSClasses } from "../util/browser.js";
import { suggestionMarks } from "./suggestionMarks.js";
@@ -7,7 +8,7 @@ export const BlockGroup = Node.create<{
domAttributes?: BlockNoteDOMAttributes;
}>({
name: "blockGroup",
- group: "childContainer",
+ group: CHILD_CONTAINER_GROUP,
content: "blockGroupChild+",
marks() {
return suggestionMarks(this.editor);
diff --git a/packages/core/src/schema/blocks/children.test.ts b/packages/core/src/schema/blocks/children.test.ts
new file mode 100644
index 0000000000..0945e577c0
--- /dev/null
+++ b/packages/core/src/schema/blocks/children.test.ts
@@ -0,0 +1,156 @@
+// @vitest-environment node
+import { describe, expect, it } from "vite-plus/test";
+
+import { childrenContentExpression } from "./children.js";
+import type { ChildrenConfig } from "./types.js";
+import { validateChildrenConfigs } from "./validateChildren.js";
+
+// All enforcement happens through the content expression. If this table is
+// right, `allow`/`min` are enforced by ProseMirror itself.
+const CASES: [string, ChildrenConfig, string][] = [
+ [
+ "any block or placeable container, at least one (the minimal config)",
+ { allow: "blocks" },
+ "blockGroupChild+",
+ ],
+ ["any block, possibly none", { allow: "blocks", min: 0 }, "blockGroupChild*"],
+ [
+ "any block, two or more",
+ { allow: "blocks", min: 2 },
+ "blockGroupChild{2,}",
+ ],
+ ["one container type only", { allow: ["column"], min: 2 }, "column{2,}"],
+ [
+ "several container types",
+ { allow: ["column", "card"] },
+ "(column | card)+",
+ ],
+];
+
+describe("childrenContentExpression", () => {
+ it.each(CASES)("%s", (_name, config, expected) => {
+ expect(childrenContentExpression(config)).toBe(expected);
+ });
+
+ // `validateChildrenConfigs` never builds the content expression — it only
+ // resolves `allow`/`min` — so an `allow` that permits nothing is caught
+ // here, at expression build, rather than by `validate` below.
+ it("throws for an allow array that permits nothing", () => {
+ expect(() => childrenContentExpression({ allow: [] })).toThrow(
+ /permits nothing/,
+ );
+ });
+});
+
+type ContainerFixture = {
+ children: ChildrenConfig;
+ content?: "none" | "inline" | "plain";
+ placeable?: "anywhere" | "namedOnly";
+};
+
+function specsWith(containers: Record) {
+ return {
+ paragraph: { config: { content: "inline" as const } },
+ heading: { config: { content: "inline" as const } },
+ ...Object.fromEntries(
+ Object.entries(containers).map(
+ ([type, { children, content, placeable }]) => [
+ type,
+ {
+ config: {
+ content: content ?? ("none" as const),
+ children,
+ placeable,
+ },
+ },
+ ],
+ ),
+ ),
+ };
+}
+
+const validate = (containers: Record) => () =>
+ validateChildrenConfigs(specsWith(containers));
+
+describe("validateChildrenConfigs", () => {
+ it("accepts recursive containers, named-only children, and titled blocks", () => {
+ expect(
+ validate({ callout: { children: { allow: "blocks" } } }),
+ ).not.toThrow();
+ expect(
+ validate({
+ // gridCell is a terminating alternative to the recursive grid.
+ grid: { children: { allow: ["gridCell", "grid"], min: 2 } },
+ gridCell: { children: { allow: "blocks" }, placeable: "namedOnly" },
+ alert: { children: { allow: "blocks" }, content: "inline" },
+ source: { children: { allow: "blocks" }, content: "plain" },
+ }),
+ ).not.toThrow();
+ });
+
+ it.each(["inline", "plain"] as const)(
+ "rejects %s child restrictions the shared blockGroup cannot enforce",
+ (content) => {
+ for (const children of [
+ { allow: "blocks", min: 2 },
+ { allow: ["cell"] },
+ ] as const) {
+ expect(
+ validate({
+ alert: { content, children },
+ cell: { children: { allow: "blocks" } },
+ }),
+ ).toThrow(/blocks with inline or plain content support/);
+ }
+ },
+ );
+
+ it("does not treat a titled block's content node as an allowed container", () => {
+ expect(
+ validate({
+ box: { children: { allow: ["alert"] } },
+ alert: { content: "inline", children: { allow: "blocks" } },
+ }),
+ ).toThrow(/regular block/);
+ });
+
+ it("rejects named-only placement on a shared regular block wrapper", () => {
+ expect(
+ validate({
+ alert: {
+ content: "inline",
+ children: { allow: "blocks" },
+ placeable: "namedOnly",
+ },
+ }),
+ ).toThrow(/requires a container node/);
+ });
+
+ // Tables do not support owned child blocks.
+ it("rejects children combined with table content", () => {
+ for (const content of ["table"] as const) {
+ expect(() =>
+ validateChildrenConfigs({
+ box: {
+ config: { content, children: { allow: "blocks" } },
+ },
+ }),
+ ).toThrow(/not supported on table blocks/);
+ }
+ });
+
+ it.each(["typo", "blockGroupChild", "toString"])(
+ "rejects an allow entry that is not a configured block: %s",
+ (allowed) => {
+ expect(validate({ box: { children: { allow: [allowed] } } })).toThrow(
+ /not a configured block type/,
+ );
+ },
+ );
+
+ it("rejects a regular block type in the allow array", () => {
+ expect(validate({ box: { children: { allow: ["heading"] } } })).toThrow(
+ /not yet supported/,
+ );
+ });
+});
diff --git a/packages/core/src/schema/blocks/children.ts b/packages/core/src/schema/blocks/children.ts
new file mode 100644
index 0000000000..7b4dd04071
--- /dev/null
+++ b/packages/core/src/schema/blocks/children.ts
@@ -0,0 +1,117 @@
+import type { Node, NodeType, Schema } from "prosemirror-model";
+
+import type { ChildrenConfig } from "./types.js";
+
+export const CHILD_CONTAINER_GROUP = "childContainer";
+
+export const BLOCK_GROUP_CHILD_GROUP = "blockGroupChild";
+
+/**
+ * Whether a block config declares a *container block*: one whose own node
+ * holds its children. A block that has content of its own keeps its ordinary
+ * shape, and its `children` declare owned children instead.
+ * @internal
+ */
+export function isContainerConfig(config: {
+ content: string;
+ children?: unknown;
+}): boolean {
+ return config.children !== undefined && config.content === "none";
+}
+
+// Whether `type` is a node that holds child blocks directly: a container
+// block's own node. A container is a child-holding node that is itself a
+// block; `blockGroup` also holds children but is not a block (it's regular
+// blocks' nesting machinery), so the `bnBlock` check excludes it.
+export function isContainerNode(type: NodeType): boolean {
+ return type.isInGroup(CHILD_CONTAINER_GROUP) && type.isInGroup("bnBlock");
+}
+
+/**
+ * Whether `node` is a block whose children are owned children: a container
+ * block, or a `blockContainer` whose content node declares `children`.
+ */
+export function hasOwnedChildren(node: Node): boolean {
+ return (
+ isContainerNode(node.type) ||
+ (node.type.name === "blockContainer" &&
+ node.firstChild?.type.spec.blockConfig?.children !== undefined)
+ );
+}
+
+// Builds the `blockGroup` node that holds a block's children when converting
+// blocks to nodes. Transaction-level nesting (`sinkItem`, `findWrapping` in the
+// keyboard shortcuts) wraps existing nodes in a `blockGroup` instead, and the
+// document's root `blockGroup` is created by the parsers and `y`/`yjs` utils.
+export function createBlockGroup(
+ schema: Schema,
+ children: readonly Node[],
+): Node {
+ return schema.nodes["blockGroup"].createChecked({}, children as Node[]);
+}
+
+/**
+ * Whether `type` is a container declared `placeable: "namedOnly"`: one
+ * defined only in terms of the container that holds it (a `column`), so it can
+ * never stand where a regular block goes.
+ *
+ * The schema encodes this by keeping such types out of
+ * `BLOCK_GROUP_CHILD_GROUP`, which is how ProseMirror enforces it while
+ * matching content. This answers the same question from the declaration
+ * itself, for code reasoning about the block rather than about what PM will
+ * match.
+ */
+export function isNamedOnly(type: NodeType): boolean {
+ return (
+ isContainerNode(type) && type.spec.blockConfig?.placeable === "namedOnly"
+ );
+}
+
+// Below `blockContainer`'s priority (50) so PM's `fillBefore` picks
+// `blockContainer` first, avoiding recursion through nested containers.
+export const CONTAINER_NODE_PRIORITY = 40;
+
+const CONTAINER_PRIORITY_BAND = { min: 30, max: 49 };
+const DEFAULT_SPEC_PRIORITY = 101;
+
+// Maps `sortByDependencies` priority into the container band (30–49).
+// Preserves relative order but keeps all containers below regular blocks.
+export function containerNodePriority(priority: number | undefined): number {
+ if (priority === undefined) {
+ return CONTAINER_NODE_PRIORITY;
+ }
+
+ const steps = Math.round((priority - DEFAULT_SPEC_PRIORITY) / 10);
+
+ return Math.min(
+ CONTAINER_PRIORITY_BAND.max,
+ Math.max(CONTAINER_PRIORITY_BAND.min, CONTAINER_NODE_PRIORITY + steps),
+ );
+}
+
+/**
+ * Compiles a container's `children` config into its node's ProseMirror content
+ * expression: which types may be its children (`allow`), followed by how few
+ * of them it takes (`min`).
+ */
+export function childrenContentExpression(children: ChildrenConfig): string {
+ const { allow, min = 1 } = children;
+
+ let allowed: string;
+ if (allow === "blocks") {
+ // "Anything" is already a group, so use it rather than spelling out a
+ // union that would need rebuilding whenever the schema gains a container
+ // type.
+ allowed = BLOCK_GROUP_CHILD_GROUP;
+ } else {
+ if (allow.length === 0) {
+ throw new Error(
+ "Container `allow` permits nothing. A container must accept at least one block or container type; drop `children` entirely for a block that holds none.",
+ );
+ }
+
+ allowed = allow.length === 1 ? allow[0] : `(${allow.join(" | ")})`;
+ }
+
+ return allowed + (min === 0 ? "*" : min === 1 ? "+" : `{${min},}`);
+}
diff --git a/packages/core/src/schema/blocks/containerAttributes.ts b/packages/core/src/schema/blocks/containerAttributes.ts
new file mode 100644
index 0000000000..c25677c8f2
--- /dev/null
+++ b/packages/core/src/schema/blocks/containerAttributes.ts
@@ -0,0 +1,37 @@
+import { camelToDataKebab } from "../../util/string.js";
+import { PropSchema, Props } from "../propTypes.js";
+
+/**
+ * Writes the attributes a container block's round-trip parse reads onto its
+ * root element: the `data-node-type` marker, the block's non-default props as
+ * `data-*` (the convention `propsToAttributes` and the generated parse rules
+ * use), and its id where there is one.
+ * Existing attributes follow the block props, including removing defaults.
+ * @internal
+ */
+export function applyContainerAttributes(
+ element: HTMLElement | undefined | null,
+ blockType: string,
+ blockProps: Partial>,
+ propSchema: PSchema,
+ id?: string,
+) {
+ if (!element) {
+ return;
+ }
+
+ for (const [prop, value] of Object.entries(blockProps)) {
+ const attribute = camelToDataKebab(prop);
+ if (value === undefined || value === propSchema[prop]?.default) {
+ element.removeAttribute(attribute);
+ } else {
+ element.setAttribute(attribute, String(value));
+ }
+ }
+
+ // Reserved markers win even when a prop maps to the same attribute.
+ element.setAttribute("data-node-type", blockType);
+ if (id) {
+ element.setAttribute("data-id", id);
+ }
+}
diff --git a/packages/core/src/schema/blocks/createSpec.browser.test.ts b/packages/core/src/schema/blocks/createSpec.browser.test.ts
new file mode 100644
index 0000000000..7505f47068
--- /dev/null
+++ b/packages/core/src/schema/blocks/createSpec.browser.test.ts
@@ -0,0 +1,176 @@
+import { Fragment } from "prosemirror-model";
+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 "./createSpec.js";
+
+// Every test here goes through `tryParseHTMLToBlocks`, which parses real HTML
+// into a real DOM (`document.implementation.createHTMLDocument` in
+// `api/parsers/html/util/nestedLists.ts`) before ProseMirror's parser ever
+// runs. Parsing HTML is the capability under test, so the whole suite runs
+// against a real browser engine rather than jsdom's.
+
+const renderDiv = () => {
+ const dom = document.createElement("div");
+ return { dom, contentDOM: dom };
+};
+
+// A pure container that recognizes its own external HTML. Before containers
+// went through `getParseRules`, `parse` was silently dropped for them and this
+// produced nothing at all.
+const Card = createBlockSpec(
+ {
+ type: "card" as const,
+ propSchema: { tone: { default: "neutral" } },
+ content: "none",
+ children: { allow: "blocks" },
+ },
+ {
+ render: renderDiv,
+ parse: (el) =>
+ el.classList.contains("card")
+ ? { tone: el.getAttribute("data-tone") ?? undefined }
+ : undefined,
+ },
+)();
+
+// The same, but taking over the parsing of its own body.
+const Quote = createBlockSpec(
+ {
+ type: "quote" as const,
+ propSchema: {},
+ content: "none",
+ children: { allow: "blocks" },
+ },
+ {
+ render: renderDiv,
+ parse: (el) => (el.tagName === "BLOCKQUOTE" ? {} : undefined),
+ // Returns inline nodes, the natural thing to build from an element, and
+ // relies on ProseMirror's parser to wrap them into child blocks.
+ parseContent: ({ el, schema }) =>
+ Fragment.from(schema.text(el.textContent?.trim() || "empty")),
+ },
+)();
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ card: Card,
+ quote: Quote,
+ } as const,
+});
+
+let editor: BlockNoteEditor;
+const div = document.createElement("div");
+
+beforeAll(() => {
+ document.body.appendChild(div);
+ editor = BlockNoteEditor.create({ schema }) as any;
+ editor.mount(div);
+});
+
+afterAll(() => {
+ editor._tiptapEditor.destroy();
+ div.remove();
+ editor = undefined as any;
+});
+
+beforeEach(() => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Paragraph 0" },
+ ]);
+});
+
+describe("container `parse`", () => {
+ it("parses an external element into a container, children intact", () => {
+ const blocks = editor.tryParseHTMLToBlocks(
+ 'First
Second
',
+ );
+
+ expect(blocks).toHaveLength(1);
+ expect(blocks[0].type).toBe("card");
+ expect(blocks[0].props.tone).toBe("warning");
+ // No `getContent` is supplied, so ProseMirror parses the children with the
+ // normal block rules and `findWrapping` adds the `blockContainer`s.
+ expect(blocks[0].children.map((child: any) => child.type)).toEqual([
+ "paragraph",
+ "heading",
+ ]);
+ expect(blocks[0].children[0].content).toEqual([
+ { type: "text", text: "First", styles: {} },
+ ]);
+ });
+
+ it("places inline nodes returned by `parseContent` into a child block", () => {
+ const blocks = editor.tryParseHTMLToBlocks(
+ "Quoted text
",
+ );
+
+ expect(blocks).toHaveLength(1);
+ expect(blocks[0].type).toBe("quote");
+ expect(blocks[0].children.map((child: any) => child.type)).toEqual([
+ "paragraph",
+ ]);
+ expect(blocks[0].children[0].content).toEqual([
+ { type: "text", text: "Quoted text", styles: {} },
+ ]);
+ });
+});
+
+describe("container `runsBefore`", () => {
+ const ambiguous = (type: string) =>
+ createBlockSpec(
+ {
+ type,
+ propSchema: {},
+ content: "none",
+ children: { allow: "blocks" },
+ } as any,
+ {
+ render: renderDiv,
+ parse: (el: HTMLElement) =>
+ el.classList.contains("shared") ? {} : undefined,
+ },
+ );
+
+ const makeEditor = (betaRunsBefore?: string[]) => {
+ const alpha = ambiguous("alpha")();
+ const beta = ambiguous("beta")();
+ if (betaRunsBefore) {
+ (beta.implementation as any).runsBefore = betaRunsBefore;
+ }
+
+ return BlockNoteEditor.create({
+ schema: BlockNoteSchema.create().extend({
+ blockSpecs: { ...defaultBlockSpecs, alpha, beta } as any,
+ }),
+ }) as BlockNoteEditor;
+ };
+
+ it("orders a container's parse rules before another container's", () => {
+ // Declaration order wins by default; `runsBefore` overrides it.
+ for (const [runsBefore, winner] of [
+ [undefined, "alpha"],
+ [["alpha"], "beta"],
+ ] as const) {
+ const other = makeEditor(runsBefore ? [...runsBefore] : undefined);
+ try {
+ expect(
+ other.tryParseHTMLToBlocks('')[0]
+ .type,
+ ).toBe(winner);
+ } finally {
+ other._tiptapEditor.destroy();
+ }
+ }
+ });
+});
diff --git a/packages/core/src/schema/blocks/createSpec.test.ts b/packages/core/src/schema/blocks/createSpec.test.ts
index c42a9151ad..d8380e4f13 100644
--- a/packages/core/src/schema/blocks/createSpec.test.ts
+++ b/packages/core/src/schema/blocks/createSpec.test.ts
@@ -1,3 +1,4 @@
+import { Node as TiptapNode } from "@tiptap/core";
import { describe, expect, it } from "vite-plus/test";
import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js";
@@ -5,6 +6,8 @@ import { defaultBlockSpecs } from "../../blocks/defaultBlocks.js";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { YAttributionMarksExtension } from "../../y/extensions/YAttributionMarks.js";
import { createBlockConfig, createBlockSpec } from "../index.js";
+import { containerRootDOM } from "./createSpec.js";
+import { createBlockSpecFromTiptapNode } from "./internal.js";
// A minimal "plain" content block WITHOUT a custom `parseContent`, so parsing
// its HTML exercises the generic plain branch in `getParseRules`' `getContent`.
@@ -121,3 +124,259 @@ describe("plain content parsing", () => {
editor._tiptapEditor.destroy();
});
});
+
+describe("block spec and node agreement", () => {
+ it("leaves a hand-written node's content expression authoritative", () => {
+ expect(() =>
+ BlockNoteSchema.create().extend({
+ blockSpecs: {
+ holder: createBlockSpecFromTiptapNode(
+ {
+ node: TiptapNode.create({
+ name: "holder",
+ group: "blockContent",
+ content: "paragraph+",
+ }),
+ type: "holder",
+ content: "none",
+ },
+ {},
+ ),
+ },
+ }),
+ ).not.toThrow();
+ });
+
+ it("rejects a hand-written node whose name contradicts its config", () => {
+ expect(() =>
+ BlockNoteSchema.create().extend({
+ blockSpecs: {
+ holder: createBlockSpecFromTiptapNode(
+ {
+ node: TiptapNode.create({
+ name: "notHolder",
+ group: "block",
+ content: "block+",
+ }),
+ type: "holder",
+ content: "none",
+ children: { allow: "blocks" },
+ },
+ {},
+ ),
+ },
+ }),
+ ).toThrow(/Node name does not match block type/);
+ });
+});
+
+describe("containerRootDOM", () => {
+ const element = () => document.createElement("div");
+
+ it("returns the dom itself when it is an element", () => {
+ const dom = element();
+ expect(containerRootDOM({ dom })).toBe(dom);
+ });
+
+ it("unwraps a fragment wrapping exactly one element", () => {
+ const root = element();
+ const fragment = document.createDocumentFragment();
+ fragment.append(root);
+ expect(containerRootDOM({ dom: fragment })).toBe(root);
+ });
+
+ it("returns null for a fragment with no single element root", () => {
+ const empty = document.createDocumentFragment();
+ expect(containerRootDOM({ dom: empty })).toBeNull();
+
+ const multi = document.createDocumentFragment();
+ multi.append(element(), element());
+ expect(containerRootDOM({ dom: multi })).toBeNull();
+
+ const textOnly = document.createDocumentFragment();
+ textOnly.append(document.createTextNode("text"));
+ expect(containerRootDOM({ dom: textOnly })).toBeNull();
+ });
+});
+
+describe("container children parsing", () => {
+ const renderDiv = () => {
+ const dom = document.createElement("div");
+ return { dom, contentDOM: dom };
+ };
+
+ const MixedBox = createBlockSpec(
+ {
+ type: "mixedBox" as const,
+ propSchema: {},
+ content: "none",
+ children: { allow: "blocks" },
+ },
+ { render: renderDiv },
+ )();
+
+ const createEditor = () =>
+ BlockNoteEditor.create({
+ schema: BlockNoteSchema.create({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ mixedBox: MixedBox,
+ },
+ }),
+ });
+
+ it("wraps loose text around blocks into child blocks without parseContent", () => {
+ const editor = createEditor();
+
+ const blocks = editor.tryParseHTMLToBlocks(
+ `First
Loose text`,
+ );
+
+ expect(blocks).toHaveLength(1);
+ expect(blocks[0].type).toBe("mixedBox");
+ expect(blocks[0].children.map((child: any) => child.type)).toEqual([
+ "paragraph",
+ "paragraph",
+ ]);
+ expect(
+ blocks[0].children.map((child: any) => child.content?.[0]?.text),
+ ).toEqual(["First", "Loose text"]);
+
+ editor._tiptapEditor.destroy();
+ });
+});
+
+describe("container render contract", () => {
+ const renderDiv = () => {
+ const dom = document.createElement("div");
+ return { dom, contentDOM: dom };
+ };
+
+ const createEditorWith = (spec: any) =>
+ BlockNoteEditor.create({
+ schema: BlockNoteSchema.create({
+ blockSpecs: { ...defaultBlockSpecs, probed: spec },
+ }),
+ });
+
+ it("rejects a container declaring neither render nor renderFrame", () => {
+ expect(() =>
+ createBlockSpec(
+ {
+ // @ts-expect-error render is required on every block.
+ type: "probed" as const,
+ propSchema: {},
+ content: "none" as const,
+ children: { allow: "blocks" },
+ },
+ {},
+ )(),
+ ).toThrow(/must declare `render`/);
+ });
+
+ it("rejects renderFrame alone on a block that is not a pure container", () => {
+ // A regular block always renders through `render`.
+ expect(() =>
+ createBlockSpec(
+ {
+ // @ts-expect-error render is required on every block.
+ type: "probed" as const,
+ propSchema: {},
+ content: "inline" as const,
+ },
+ {
+ renderFrame: () => {
+ const dom = document.createElement("div");
+ return { dom, slot: dom };
+ },
+ },
+ )(),
+ ).toThrow(/must declare `render`/);
+
+ // A titled block needs its title row: `renderFrame` alone is not enough.
+ expect(() =>
+ createBlockSpec(
+ {
+ // @ts-expect-error render is required on every block.
+ type: "probed" as const,
+ propSchema: {},
+ content: "inline" as const,
+ children: { allow: "blocks" },
+ },
+ {
+ renderFrame: () => {
+ const dom = document.createElement("div");
+ return { dom, slot: dom };
+ },
+ },
+ )(),
+ ).toThrow(/must declare `render`/);
+ });
+
+ it("rejects framing a pure container, whose render already owns its box", () => {
+ const framed = createBlockSpec(
+ {
+ type: "probed",
+ propSchema: {},
+ content: "none",
+ children: { allow: "blocks" },
+ },
+ {
+ render: renderDiv,
+ renderFrame: () => {
+ const dom = document.createElement("div");
+ return { dom, slot: dom };
+ },
+ },
+ )();
+ expect(() => createEditorWith(framed)).toThrow(
+ /requires a separate content node/,
+ );
+ });
+});
+
+it("scopes external container parsing to childrenDOM", () => {
+ const box = createBlockSpec(
+ {
+ type: "box",
+ propSchema: {},
+ content: "none",
+ children: { allow: "blocks" },
+ },
+ {
+ render() {
+ const dom = document.createElement("div");
+ return { dom, contentDOM: dom };
+ },
+ toExternalHTML() {
+ const dom = document.createElement("div");
+ const label = document.createElement("button");
+ label.textContent = "Control label";
+ const childrenDOM = document.createElement("div");
+ dom.append(label, childrenDOM);
+ return { dom, childrenDOM };
+ },
+ },
+ )();
+ const editor = BlockNoteEditor.create({
+ schema: BlockNoteSchema.create({
+ blockSpecs: { ...defaultBlockSpecs, box },
+ }),
+ initialContent: [
+ { type: "box", children: [{ type: "paragraph", content: "Body" }] },
+ ],
+ });
+ try {
+ const html = editor.blocksToHTMLLossy(editor.document);
+ expect(html).toContain('data-children-of="box"');
+ const parsed = editor.tryParseHTMLToBlocks(html);
+ expect(parsed).toHaveLength(1);
+ expect(parsed[0].type).toBe("box");
+ expect(parsed[0].children).toHaveLength(1);
+ expect(parsed[0].children[0].content).toEqual([
+ { type: "text", text: "Body", styles: {} },
+ ]);
+ } finally {
+ editor._tiptapEditor.destroy();
+ }
+});
diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts
index 22fb91c321..02e1902aa3 100644
--- a/packages/core/src/schema/blocks/createSpec.ts
+++ b/packages/core/src/schema/blocks/createSpec.ts
@@ -1,4 +1,4 @@
-import { Editor, Node } from "@tiptap/core";
+import { Editor, Node, NodeViewRendererProps } from "@tiptap/core";
import {
DOMParser,
Fragment,
@@ -6,6 +6,7 @@ import {
TagParseRule,
} from "@tiptap/pm/model";
import { NodeView } from "@tiptap/pm/view";
+import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js";
import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js";
import {
Extension,
@@ -13,9 +14,20 @@ import {
} from "../../editor/BlockNoteExtension.js";
import { nonFormattingMarks } from "../markGroups.js";
import { ignoreNonContentMutations } from "../nodeViewMutations.js";
+import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js";
import { PropSchema } from "../propTypes.js";
import {
+ BLOCK_GROUP_CHILD_GROUP,
+ CHILD_CONTAINER_GROUP,
+ childrenContentExpression,
+ containerNodePriority,
+ isContainerConfig,
+} from "./children.js";
+import { applyContainerAttributes } from "./containerAttributes.js";
+import {
+ applyDOMAttributes,
getBlockFromNodeView,
+ isDocumentFragment,
propsToAttributes,
wrapInBlockStructure,
} from "./internal.js";
@@ -45,9 +57,115 @@ export function applyNonSelectableBlockFix(nodeView: NodeView, editor: Editor) {
};
}
-// Function that uses the 'parse' function of a blockConfig to create a
-// TipTap node's `parseHTML` property. This is only used for parsing content
-// from the clipboard.
+// Finds the element holding a serialized container block's children, marked
+// `data-children-of` by the internal HTML serializer. Returns undefined when
+// no marker belonging to *this* block (rather than a same-typed nested
+// container) is present.
+function findContainerContentElement(
+ el: HTMLElement,
+ config: { type: string },
+): HTMLElement | undefined {
+ const selector = `[data-children-of="${config.type}"]`;
+
+ // The block's root may itself be the children host (a render that passes
+ // its own root to `contentRef`). `querySelectorAll` only sees descendants.
+ if (el.matches(selector)) {
+ return el;
+ }
+
+ for (const host of el.querySelectorAll(selector)) {
+ // Skip hosts of same-typed *nested* containers: this block's own host is
+ // the one with no other container root between it and `el`.
+ if (host.parentElement?.closest("[data-node-type]") === el) {
+ return host;
+ }
+ }
+
+ return undefined;
+}
+
+// Custom parsing, followed by the default parser for the block's content kind.
+function blockContentParser<
+ TName extends string,
+ TProps extends PropSchema,
+ TContent extends "inline" | "none" | "table" | "plain",
+>(
+ config: BlockConfig,
+ implementation: BlockImplementation,
+): TagParseRule["getContent"] | undefined {
+ const isContainer = isContainerConfig(config);
+ if (
+ config.content === "table" ||
+ (isContainer && !implementation.parseContent)
+ ) {
+ // Tables use their own rules. Containers without parseContent use PM's
+ // normal child parsing, including wrapping inline runs as paragraphs.
+ return undefined;
+ }
+
+ return (node, schema) => {
+ if (implementation.parseContent) {
+ const result = implementation.parseContent({
+ el: node as HTMLElement,
+ schema,
+ });
+ // parseContent may return undefined to fall through to the default
+ // inline content parsing below.
+ if (result !== undefined) {
+ return result;
+ }
+ }
+
+ if (isContainer) {
+ return DOMParser.fromSchema(schema).parse(node as HTMLElement, {
+ topNode: schema.nodes["blockGroup"].create(),
+ preserveWhitespace: true,
+ }).content;
+ }
+
+ if (config.content === "none") {
+ return Fragment.empty;
+ }
+
+ // Cloned so merging doesn't modify the element being parsed.
+ const clone = (node as HTMLElement).cloneNode(true) as HTMLElement;
+ // Merge multiple paragraphs into one with line breaks
+ mergeParagraphs(
+ clone,
+ config.content === "plain" || implementation.meta?.code ? "\n" : "
",
+ );
+
+ // Parsed as a paragraph, to extract the inline content by itself.
+ const parsed = DOMParser.fromSchema(schema).parse(clone, {
+ topNode: schema.nodes.paragraph.create(),
+ preserveWhitespace: config.content === "plain" ? "full" : true,
+ });
+
+ if (config.content === "inline") {
+ return parsed.content;
+ }
+
+ // Plain blocks hold text only, so non-text inline nodes are flattened:
+ // line breaks become newline characters and other nodes (e.g. mentions)
+ // are kept as their text.
+ const textNodes: PMNode[] = [];
+ parsed.content.forEach((child) => {
+ if (child.isText) {
+ textNodes.push(child);
+ return;
+ }
+ const text =
+ child.type === schema.linebreakReplacement ? "\n" : child.textContent;
+ if (text) {
+ textNodes.push(schema.text(text, child.marks));
+ }
+ });
+
+ return Fragment.fromArray(textNodes);
+ };
+}
+
+// Creates `parseHTML` rules for clipboard parsing.
export function getParseRules<
TName extends string,
TProps extends PropSchema,
@@ -56,11 +174,27 @@ export function getParseRules<
config: BlockConfig,
implementation: BlockImplementation,
) {
+ const isContainer = isContainerConfig(config);
+
const rules: TagParseRule[] = [
- {
- tag: "[data-content-type=" + config.type + "]",
- contentElement: ".bn-inline-content",
- },
+ isContainer
+ ? {
+ tag: `[data-node-type=${config.type}]`,
+ // Scope the round-trip parse to the block's content region, so text
+ // the render puts elsewhere in its DOM (button labels, captions,
+ // ...) doesn't parse back as document content. The internal HTML
+ // serializer marks the region with `data-children-of`; HTML without
+ // the marker (older or hand-written) falls back to the whole
+ // element, the previous behavior.
+ contentElement: (el) =>
+ findContainerContentElement(el as HTMLElement, config) ??
+ (el as HTMLElement),
+ }
+ : {
+ tag: "[data-content-type=" + config.type + "]",
+ contentElement: ".bn-inline-content",
+ preserveWhitespace: config.content === "plain" ? "full" : undefined,
+ },
];
if (implementation.parse) {
@@ -81,280 +215,241 @@ export function getParseRules<
},
// Because we do the parsing ourselves, we want to preserve whitespace for content we've parsed
preserveWhitespace: true,
- getContent:
- config.content === "inline" ||
- config.content === "none" ||
- config.content === "plain"
- ? (node, schema) => {
- if (implementation.parseContent) {
- const result = implementation.parseContent({
- el: node as HTMLElement,
- schema,
- });
- // parseContent may return undefined to fall through to
- // the default inline content parsing below.
- if (result !== undefined) {
- return result;
- }
- }
-
- if (config.content === "inline" || config.content === "plain") {
- // Parse the inline content if it exists
- const element = node as HTMLElement;
-
- // Clone to avoid modifying the original
- const clone = element.cloneNode(true) as HTMLElement;
-
- // Merge multiple paragraphs into one with line breaks
- mergeParagraphs(
- clone,
- config.content === "plain" || implementation.meta?.code
- ? "\n"
- : "
",
- );
-
- // Parse the content directly as a paragraph to extract inline content
- const parser = DOMParser.fromSchema(schema);
- const parsed = parser.parse(clone, {
- topNode: schema.nodes.paragraph.create(),
- preserveWhitespace: true,
- });
-
- if (config.content === "plain") {
- // Plain blocks hold text only, so non-text inline nodes are
- // flattened: line breaks become newline characters and other
- // nodes (e.g. mentions) are kept as their text.
- const textNodes: PMNode[] = [];
- parsed.content.forEach((child) => {
- if (child.isText) {
- textNodes.push(child);
- } else {
- const text =
- child.type === schema.linebreakReplacement
- ? "\n"
- : child.textContent;
- if (text) {
- textNodes.push(schema.text(text, child.marks));
- }
- }
- });
-
- return Fragment.fromArray(textNodes);
- }
- return parsed.content;
- }
- return Fragment.empty;
- }
- : undefined,
+ getContent: blockContentParser(config, implementation),
});
}
- // getContent(node, schema) {
- // const block = blockConfig.parse?.(node as HTMLElement);
- //
- // if (block !== undefined && block.content !== undefined) {
- // return Fragment.from(
- // typeof block.content === "string"
- // ? schema.text(block.content)
- // : inlineContentToNodes(block.content, schema)
- // );
- // }
- //
- // return Fragment.empty;
- // },
- // });
- // }
return rules;
}
-// What the generated node's content expression is for each `content` kind.
-const CONTENT_EXPRESSIONS: Record = {
- inline: "inline*",
- plain: "text*",
- none: "",
- table: "tableRow+",
-};
-
-/**
- * Content expressions that are spelled differently can still mean the same
- * thing, e.g. `"(text)*"` and `"text*"`. Unwraps a parenthesized single
- * term, with or without a trailing quantifier, so equivalent spellings
- * compare equal. Anything with real structure (sequences, alternation) is
- * left as-is: unwrapping those would change the expression's meaning.
- */
-function normalizeContentExpression(expression: string): string {
- const trimmed = expression.trim();
- const match = trimmed.match(/^\(([A-Za-z_][A-Za-z0-9_]*)\)([*+?])?$/);
- return match ? `${match[1]}${match[2] ?? ""}` : trimmed;
+export function containerRootDOM(output: {
+ dom: HTMLElement | DocumentFragment;
+}): HTMLElement | null {
+ if (isDocumentFragment(output.dom)) {
+ // A fragment can't hold attributes, so the round-trip markers
+ // (`data-node-type`, prop `data-*`) would be lost with it as the root.
+ // When it wraps a single element (the shape a React render produces),
+ // that element is the block's real root. A multi-element fragment has no
+ // root to mark, so its container HTML can't parse back.
+ return output.dom.children.length === 1
+ ? (output.dom.children[0] as HTMLElement)
+ : null;
+ }
+ return output.dom;
}
-/**
- * Checks a hand-written node against its config: that the node name matches
- * the block type, and that the node's content expression matches the
- * `content` the spec declares — the one `getBlockInfoFromPos` reports as the
- * block's `contentKind`, without looking at the node. A generated node's name
- * and expression come from that same config, so this only bites on a
- * hand-written one (`createBlockSpecFromTiptapNode`).
- */
-function checkNodeMatchesConfig(node: Node, blockConfig: BlockConfig) {
- if (node.name !== blockConfig.type) {
- throw new Error(
- "Node name does not match block type. This is a bug in BlockNote.",
- );
- }
+function blockNodeView<
+ TName extends string,
+ TProps extends PropSchema,
+ TContent extends "inline" | "none" | "table" | "plain",
+>(
+ blockConfig: BlockConfig,
+ blockImplementation: BlockImplementation,
+ props: NodeViewRendererProps,
+): NodeView {
+ const isContainer = isContainerConfig(blockConfig);
+ const block = isContainer
+ ? nodeToBlock(props.node, props.view.state.doc)
+ : getBlockFromNodeView(props.getPos, props.node, props.view.state.doc);
+ const nodeView = blockImplementation.render.call(
+ {
+ blockContentDOMAttributes:
+ props.extension.options.domAttributes?.blockContent || {},
+ props,
+ renderType: "nodeView",
+ propSchema: blockConfig.propSchema,
+ },
+ block as any,
+ props.extension.options.editor,
+ );
+
+ const typedNodeView = nodeView as unknown as NodeView;
- // A wrapper node that holds child blocks directly (e.g. a hand-written
- // `column`) has no block content expression to compare against.
- const groups = typeof node.config.group === "string" ? node.config.group : "";
- if (groups.split(" ").includes("bnBlock")) {
- return;
+ if (blockImplementation.meta?.selectable === false) {
+ applyNonSelectableBlockFix(typedNodeView, props.editor);
}
- // tiptap allows the expression to be a function of the editor, in which case
- // there is nothing to compare yet.
- const content = node.config.content;
- if (content !== undefined && typeof content !== "string") {
- return;
+ ignoreNonContentMutations(typedNodeView);
+
+ if (!isContainer) {
+ return typedNodeView;
}
- const expected = CONTENT_EXPRESSIONS[blockConfig.content];
- if (
- normalizeContentExpression(content ?? "") !==
- normalizeContentExpression(expected)
- ) {
- throw new Error(
- `Block "${blockConfig.type}" declares \`content: "${blockConfig.content}"\`, ` +
- `but its node holds "${content ?? ""}" rather than "${expected}".`,
+ applyContainerAttributes(
+ containerRootDOM(nodeView),
+ blockConfig.type,
+ block.props as any,
+ blockConfig.propSchema,
+ block.id,
+ );
+
+ // Mark the children host in the live DOM, mirroring what the internal HTML
+ // serializer emits, so the container's round-trip parse rule can scope
+ // itself to it (`contentElement` in `getParseRules`) when ProseMirror
+ // re-reads editor DOM.
+ if (typedNodeView.contentDOM) {
+ (typedNodeView.contentDOM as HTMLElement).setAttribute(
+ "data-children-of",
+ blockConfig.type,
);
}
+
+ const update = typedNodeView.update?.bind(typedNodeView);
+ if (update) {
+ typedNodeView.update = (node, decorations, innerDecorations) => {
+ if (node.type.name !== blockConfig.type) {
+ return false;
+ }
+ if (update(node, decorations, innerDecorations) === false) {
+ return false;
+ }
+ applyContainerAttributes(
+ containerRootDOM(nodeView),
+ blockConfig.type,
+ nodeToBlock(node, props.view.state.doc).props as any,
+ blockConfig.propSchema,
+ node.attrs.id,
+ );
+ return true;
+ };
+ }
+
+ return typedNodeView;
}
-// A function to create custom block for API consumers
-// we want to hide the tiptap node from API consumers and provide a simpler API surface instead
-export function addNodeAndExtensionsToSpec<
+function buildNode<
TName extends string,
TProps extends PropSchema,
TContent extends "inline" | "none" | "table" | "plain",
>(
blockConfig: BlockConfig,
blockImplementation: BlockImplementation,
- extensions?: (ExtensionFactoryInstance | Extension)[],
priority?: number,
-): LooseBlockSpec {
- const builtNode =
- ((blockImplementation as any).node as Node) ||
- Node.create({
- name: blockConfig.type,
- content: (blockConfig.content === "inline"
+) {
+ const isContainer = isContainerConfig(blockConfig);
+ return Node.create({
+ name: blockConfig.type,
+ content: isContainer
+ ? childrenContentExpression(blockConfig.children!)
+ : blockConfig.content === "inline"
? "inline*"
: blockConfig.content === "plain"
? "text*"
: blockConfig.content === "none"
? ""
- : blockConfig.content) as TContent extends "inline"
- ? "inline*"
- : TContent extends "plain"
- ? "text*"
- : "",
- // "plain" blocks hold unstyled text, so they disallow formatting marks.
- // They still allow the non-formatting marks (comments and
- // suggestions/diffs) — those annotate content without changing it and are
- // ignored by the block model. `nonFormattingMarks` resolves the group only
- // when at least one such mark is registered, so a plain block in an editor
- // without any of them doesn't reference an empty (unknown) mark group.
- marks() {
- return blockConfig.content === "plain"
+ : blockConfig.content,
+ // "plain" blocks hold unstyled text, so they disallow formatting marks.
+ // They still allow the non-formatting marks (comments and
+ // suggestions/diffs), which annotate content without changing it and are
+ // ignored by the block model. `nonFormattingMarks` resolves the group only
+ // when at least one such mark is registered, so a plain block in an editor
+ // without any of them doesn't reference an empty (unknown) mark group.
+ marks() {
+ return isContainer
+ ? suggestionMarks(this.editor)
+ : blockConfig.content === "plain"
? nonFormattingMarks(this.editor)
: undefined;
- },
- group: "blockContent",
- selectable: blockImplementation.meta?.selectable ?? true,
- isolating: blockImplementation.meta?.isolating ?? true,
- code: blockImplementation.meta?.code ?? false,
- defining: blockImplementation.meta?.defining ?? true,
- priority,
- addAttributes() {
- return propsToAttributes(blockConfig.propSchema);
- },
-
- parseHTML() {
- return getParseRules(blockConfig, blockImplementation);
- },
-
- renderHTML({ HTMLAttributes }) {
- // renderHTML is used for copy/pasting content from the editor back into
- // the editor, so we need to make sure the `blockContent` element is
- // structured correctly as this is what's used for parsing blocks. We
- // just render a placeholder div inside as the `blockContent` element
- // already has all the information needed for proper parsing.
- const div = document.createElement("div");
- return wrapInBlockStructure(
- {
- dom: div,
- contentDOM:
- blockConfig.content === "inline" ||
- blockConfig.content === "plain"
- ? div
- : undefined,
- },
- blockConfig.type,
- {},
- blockConfig.propSchema,
- blockImplementation.meta?.fileBlockAccept !== undefined,
- HTMLAttributes,
- );
- },
+ },
+ group: isContainer
+ ? [
+ "bnBlock",
+ CHILD_CONTAINER_GROUP,
+ ...(blockConfig.placeable === "namedOnly"
+ ? []
+ : [BLOCK_GROUP_CHILD_GROUP]),
+ ].join(" ")
+ : "blockContent",
+ selectable: blockImplementation.meta?.selectable ?? true,
+ // Containers must remain open to paste across their edges; isolating
+ // makes ProseMirror wrap spanning slices in a spurious blockGroup.
+ isolating: isContainer
+ ? false
+ : (blockImplementation.meta?.isolating ?? true),
+ code: isContainer ? false : (blockImplementation.meta?.code ?? false),
+ defining: isContainer ? true : (blockImplementation.meta?.defining ?? true),
+ priority: isContainer ? containerNodePriority(priority) : priority,
+ addAttributes() {
+ return propsToAttributes(blockConfig.propSchema);
+ },
- addNodeView() {
- return (props) => {
- // Gets the BlockNote editor instance
- const editor = this.options.editor;
- // Gets the block. Resolving this can't rely on `getPos()` alone —
- // node views are constructed part-way through ProseMirror's
- // reconciliation, where positions don't always line up with
- // `view.state.doc` yet (see `getBlockFromNodeView`).
- const block = getBlockFromNodeView(
- props.getPos,
- props.node,
- props.view.state.doc,
- );
- // Gets the custom HTML attributes for `blockContent` nodes
- const blockContentDOMAttributes =
- this.options.domAttributes?.blockContent || {};
+ parseHTML() {
+ return getParseRules(blockConfig, blockImplementation);
+ },
- const nodeView = blockImplementation.render.call(
- {
- blockContentDOMAttributes,
- props,
- renderType: "nodeView",
- propSchema: blockConfig.propSchema,
- },
- block as any,
- editor as any,
- );
+ renderHTML({ HTMLAttributes }) {
+ if (isContainer) {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-node-type", blockConfig.type);
+ for (const [attribute, value] of Object.entries(HTMLAttributes)) {
+ dom.setAttribute(attribute, value as string);
+ }
+ return { dom, contentDOM: dom };
+ }
+
+ // renderHTML is used for copy/pasting content from the editor back into
+ // the editor, so we need to make sure the `blockContent` element is
+ // structured correctly as this is what's used for parsing blocks. We
+ // just render a placeholder div inside as the `blockContent` element
+ // already has all the information needed for proper parsing.
+ const div = document.createElement("div");
+ return wrapInBlockStructure(
+ {
+ dom: div,
+ contentDOM:
+ blockConfig.content === "inline" || blockConfig.content === "plain"
+ ? div
+ : undefined,
+ },
+ blockConfig.type,
+ {},
+ blockConfig.propSchema,
+ blockImplementation.meta?.fileBlockAccept !== undefined,
+ HTMLAttributes,
+ );
+ },
- // Cast needed because render returns `dom: HTMLElement | DocumentFragment`
- // but tiptap's NodeView expects `dom: HTMLElement`
- const typedNodeView = nodeView as unknown as NodeView;
+ addNodeView() {
+ return (props) => blockNodeView(blockConfig, blockImplementation, props);
+ },
+ });
+}
- if (blockImplementation.meta?.selectable === false) {
- applyNonSelectableBlockFix(typedNodeView, this.editor);
- }
+// A function to create custom block for API consumers
+// we want to hide the tiptap node from API consumers and provide a simpler API surface instead
+export function addNodeAndExtensionsToSpec<
+ TName extends string,
+ TProps extends PropSchema,
+ TContent extends "inline" | "none" | "table" | "plain",
+>(
+ blockConfig: BlockConfig,
+ blockImplementation: BlockImplementation,
+ extensions?: (ExtensionFactoryInstance | Extension)[],
+ priority?: number,
+): LooseBlockSpec {
+ // Only a contentless block builds a container node. A block with content of
+ // its own keeps its ordinary shape, and its `children` are owned children
+ // instead.
+ const isContainer = isContainerConfig(blockConfig);
- // Ignores DOM mutations that don't affect the block's content, so
- // that browser extensions which rewrite the DOM (e.g. Dark Reader)
- // can't trigger an infinite re-render loop that freezes the tab.
- ignoreNonContentMutations(typedNodeView);
+ const builtNode: Node =
+ (blockImplementation as any).node ??
+ buildNode(blockConfig, blockImplementation, priority);
- // See explanation for why `update` is not implemented for NodeViews
- // https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464
- // https://github.com/TypeCellOS/BlockNote/issues/220
- return typedNodeView;
- };
- },
- });
+ if (builtNode.name !== blockConfig.type) {
+ throw new Error(
+ "Node name does not match block type. This is a bug in BlockNote.",
+ );
+ }
- checkNodeMatchesConfig(builtNode, blockConfig as BlockConfig);
+ if (!blockImplementation.render) {
+ throw new Error(`Block "${blockConfig.type}" must declare \`render\`.`);
+ }
+ if (isContainer && blockImplementation.renderFrame) {
+ throw new Error(
+ `Container block "${blockConfig.type}" draws its box in \`render\`; \`renderFrame\` requires a separate content node.`,
+ );
+ }
// The block's config is stored on its node's PM spec
// (`NodeSpec.blockConfig`), so code holding a bare `Node` can consult it
@@ -366,51 +461,65 @@ export function addNodeAndExtensionsToSpec<
},
});
+ function serialize(
+ block: Parameters[0],
+ editor: Parameters[1],
+ context?: { nestingLevel: number },
+ ) {
+ const blockContentDOMAttributes =
+ node.options.domAttributes?.blockContent || {};
+ const external =
+ context &&
+ blockImplementation.toExternalHTML?.call(
+ { blockContentDOMAttributes, propSchema: blockConfig.propSchema },
+ block,
+ editor as any,
+ context,
+ );
+ const output =
+ external ??
+ blockImplementation.render.call(
+ {
+ blockContentDOMAttributes,
+ props: undefined,
+ renderType: "dom",
+ propSchema: blockConfig.propSchema,
+ },
+ block,
+ editor as any,
+ );
+
+ if (isContainer) {
+ applyContainerAttributes(
+ containerRootDOM(output),
+ blockConfig.type,
+ block.props,
+ blockConfig.propSchema,
+ block.id,
+ );
+ } else if (context && !external) {
+ // An explicit external renderer owns the complete export. Otherwise
+ // wrap the default content and children in the editor's frame.
+ const frame = blockImplementation.renderFrame?.call(
+ { renderType: "dom", props: undefined, blockContentDOMAttributes },
+ block,
+ editor as any,
+ );
+ if (frame) {
+ frame.slot.append(output.dom);
+ return { ...output, dom: frame.dom, childrenDOM: frame.slot };
+ }
+ }
+ return output;
+ }
+
return {
config: blockConfig,
implementation: {
...blockImplementation,
node,
- render(block, editor) {
- const blockContentDOMAttributes =
- node.options.domAttributes?.blockContent || {};
-
- return blockImplementation.render.call(
- {
- blockContentDOMAttributes,
- props: undefined,
- renderType: "dom",
- propSchema: blockConfig.propSchema,
- },
- block as any,
- editor as any,
- );
- },
- // TODO: this should not have wrapInBlockStructure and generally be a lot simpler
- // post-processing in externalHTMLExporter should not be necessary
- toExternalHTML: (block, editor, context) => {
- const blockContentDOMAttributes =
- node.options.domAttributes?.blockContent || {};
-
- return (
- blockImplementation.toExternalHTML?.call(
- { blockContentDOMAttributes, propSchema: blockConfig.propSchema },
- block as any,
- editor as any,
- context,
- ) ??
- blockImplementation.render.call(
- {
- blockContentDOMAttributes,
- renderType: "dom",
- props: undefined,
- propSchema: blockConfig.propSchema,
- },
- block as any,
- editor as any,
- )
- );
- },
+ render: serialize,
+ toExternalHTML: serialize,
},
extensions,
};
@@ -520,6 +629,16 @@ export function createBlockSpec<
: extensionsOrCreator
: undefined;
+ // Only a contentless block is a container here. A block with content of
+ // its own keeps its ordinary shape, and its `children` are owned children
+ // instead.
+ const isContainer = isContainerConfig(blockConfig);
+
+ // Keep the existing render contract, including for JS callers.
+ if (!blockImplementation.render) {
+ throw new Error(`Block "${blockConfig.type}" must declare \`render\`.`);
+ }
+
return {
config: blockConfig,
implementation: {
@@ -538,6 +657,11 @@ export function createBlockSpec<
return undefined;
}
+ if (isContainer) {
+ applyDOMAttributes(output.dom, this.blockContentDOMAttributes);
+ return output;
+ }
+
return wrapInBlockStructure(
output,
block.type,
@@ -557,6 +681,11 @@ export function createBlockSpec<
editor as any,
);
+ if (isContainer) {
+ applyDOMAttributes(output.dom, this.blockContentDOMAttributes);
+ return output;
+ }
+
const nodeView = wrapInBlockStructure(
output,
block.type,
diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts
index cfd17b9d11..cc8970940e 100644
--- a/packages/core/src/schema/blocks/internal.ts
+++ b/packages/core/src/schema/blocks/internal.ts
@@ -6,7 +6,7 @@ import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.j
import { mergeCSSClasses } from "../../util/browser.js";
import { camelToDataKebab } from "../../util/string.js";
import { PropSchema, Props } from "../propTypes.js";
-import { LooseBlockSpec } from "./types.js";
+import { BlockConfig, ChildrenConfig, LooseBlockSpec } from "./types.js";
// Function that uses the 'propSchema' of a blockConfig to create a TipTap
// node's `addAttributes` property.
@@ -157,6 +157,39 @@ export function getBlockFromNodeView(
}
}
+/**
+ * `Node.DOCUMENT_FRAGMENT_NODE`, inlined. Server-side rendering shims only
+ * `document` and `window` onto the global scope, so `Node` and
+ * `DocumentFragment` are undefined there and `instanceof` throws.
+ */
+const DOCUMENT_FRAGMENT_NODE = 11;
+
+export function isDocumentFragment(
+ node: HTMLElement | DocumentFragment,
+): node is DocumentFragment {
+ return node.nodeType === DOCUMENT_FRAGMENT_NODE;
+}
+
+/**
+ * Applies custom `blockContent` DOM attributes to an element, merging (rather
+ * than overwriting) its class list.
+ */
+export function applyDOMAttributes(
+ dom: HTMLElement | DocumentFragment,
+ domAttributes: Record | undefined,
+) {
+ if (!domAttributes || isDocumentFragment(dom)) {
+ return;
+ }
+ for (const [attr, value] of Object.entries(domAttributes)) {
+ if (attr === "class") {
+ dom.className = mergeCSSClasses(dom.className, value);
+ } else {
+ dom.setAttribute(attr, value);
+ }
+ }
+}
+
// Function that wraps the `dom` element returned from 'blockConfig.render' in a
// `blockContent` div, which contains the block type and props as HTML
// attributes. If `blockConfig.render` also returns a `contentDOM`, it also adds
@@ -232,6 +265,12 @@ export function createBlockSpecFromTiptapNode<
node: Node;
type: string;
content: "inline" | "table" | "none" | "plain";
+ // Declares the block's container semantics (child counts/repair etc.)
+ // even though the node itself is hand-written. The node's own content
+ // expression stays authoritative for the PM schema, while BlockNote-level
+ // behavior (repair, seeding, validation) reads this config.
+ children?: ChildrenConfig;
+ placeable?: BlockConfig["placeable"];
},
P extends PropSchema,
>(
@@ -244,6 +283,10 @@ export function createBlockSpecFromTiptapNode<
type: config.type as T["type"],
content: config.content,
propSchema,
+ ...(config.children !== undefined ? { children: config.children } : {}),
+ ...(config.placeable !== undefined
+ ? { placeable: config.placeable }
+ : {}),
},
implementation: {
node: config.node,
diff --git a/packages/core/src/schema/blocks/renderFrame.test.ts b/packages/core/src/schema/blocks/renderFrame.test.ts
new file mode 100644
index 0000000000..cb0419b699
--- /dev/null
+++ b/packages/core/src/schema/blocks/renderFrame.test.ts
@@ -0,0 +1,450 @@
+import { describe, expect, it, vi } from "vite-plus/test";
+
+import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js";
+import { defaultBlockSpecs } from "../../blocks/defaultBlocks.js";
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import {
+ CONTAINER_SELECTOR,
+ getBlockFromElement,
+ getDraggableBlockFromElement,
+} from "../../extensions/blockDOM.js";
+import type { LooseBlockSpec } from "./types.js";
+import { createBlockSpec } from "./createSpec.js";
+
+// Behaviour of the vanilla `renderFrame` hook: a block draws the box around
+// its content and children, and may decline the frame by returning
+// `undefined` — the toggle pattern. A frame can be patched
+// in place through `update`.
+
+const renderDiv = () => {
+ const dom = document.createElement("div");
+ return { dom, contentDOM: dom };
+};
+
+// A titled block that is only framed when it says so: closed toggles draw
+// the box, open ones render as plain nesting.
+const Toggle = createBlockSpec(
+ {
+ type: "toggle" as const,
+ propSchema: {
+ mode: {
+ default: "plain",
+ values: ["plain", "framed"],
+ },
+ },
+ content: "inline",
+ children: { allow: "blocks" },
+ },
+ {
+ render: renderDiv,
+ renderFrame: (block) => {
+ if (block.props.mode !== "framed") {
+ return undefined;
+ }
+ const dom = document.createElement("div");
+ dom.className = "toggle";
+ const slot = document.createElement("div");
+ slot.className = "toggle-slot";
+ dom.append(slot);
+ return { dom, slot };
+ },
+ },
+)();
+
+// A titled block with an update hook that patches frame chrome in place.
+const FrameBox = createBlockSpec(
+ {
+ type: "frameBox" as const,
+ propSchema: {
+ flavor: {
+ default: "tip",
+ values: ["tip", "warning"],
+ },
+ },
+ content: "inline",
+ children: { allow: "blocks" },
+ },
+ {
+ render: renderDiv,
+ renderFrame: (block) => {
+ const dom = document.createElement("div");
+ dom.className = "frame-box";
+ dom.dataset.flavor = block.props.flavor;
+ const slot = document.createElement("div");
+ slot.className = "frame-slot";
+ dom.append(slot);
+ return {
+ dom,
+ slot,
+ update: (newBlock) => {
+ dom.dataset.flavor = (newBlock as any).props.flavor;
+ },
+ };
+ },
+ },
+)();
+
+const ContentFrame = createBlockSpec(
+ {
+ type: "contentFrame",
+ propSchema: {},
+ content: "inline",
+ children: { allow: "blocks" },
+ },
+ {
+ render: renderDiv,
+ meta: { draggable: false },
+ renderFrame(block) {
+ if (block.children.length === 0) {
+ return undefined;
+ }
+ const dom = document.createElement("section");
+ dom.className = "content-frame";
+ dom.dataset.title = JSON.stringify(block.content);
+ dom.dataset.count = String(block.children.length);
+ const slot = document.createElement("div");
+ dom.append(slot);
+ const fragment = document.createDocumentFragment();
+ fragment.append(dom);
+ return { dom: fragment, slot };
+ },
+ },
+)();
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ toggle: Toggle,
+ contentFrame: ContentFrame,
+ frameBox: FrameBox,
+ } as const,
+});
+
+function editorWith(initialContent: any[]) {
+ const editor = BlockNoteEditor.create({ schema, initialContent });
+ editor.mount(document.createElement("div"));
+ return editor;
+}
+
+describe("renderFrame decline", () => {
+ it("renders plain nesting when declined, frames when accepted, and flips back", () => {
+ const editor = editorWith([
+ {
+ id: "t1",
+ type: "toggle",
+ props: { mode: "plain" },
+ content: "Title",
+ children: [{ id: "c1", type: "paragraph", content: "Body" }],
+ },
+ ]);
+ try {
+ const root = editor.domElement!;
+ // Declined: ordinary nesting, no box.
+ expect(root.querySelector(".toggle")).toBeNull();
+ expect(root.textContent).toContain("Title");
+ expect(root.textContent).toContain("Body");
+
+ editor.updateBlock("t1", {
+ props: { mode: "framed" },
+ } as any);
+
+ // Accepted: the box wraps the title and the body together, and the
+ // child block survives the rebuild.
+ const framed = root.querySelector(".toggle")!;
+ expect(framed).not.toBeNull();
+ const slot = framed.querySelector(".toggle-slot")!;
+ expect(slot.textContent).toContain("Title");
+ expect(slot.querySelector('[data-id="c1"]')).not.toBeNull();
+ expect(slot.textContent).toContain("Body");
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+
+ editor.updateBlock("t1", {
+ props: { mode: "plain" },
+ } as any);
+
+ // Declined again: the box is gone, the content is intact.
+ expect(root.querySelector(".toggle")).toBeNull();
+ expect(root.textContent).toContain("Title");
+ expect(root.textContent).toContain("Body");
+ expect(root.querySelector('[data-id="c1"]')).not.toBeNull();
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ } finally {
+ editor._tiptapEditor.destroy();
+ }
+ });
+});
+
+describe("renderFrame updates", () => {
+ it("re-evaluates a declined frame when children appear and disappear", () => {
+ const editor = editorWith([
+ { id: "frame", type: "contentFrame", content: "Title" },
+ ]);
+ try {
+ expect(editor.domElement!.querySelector(".content-frame")).toBeNull();
+ editor.updateBlock("frame", {
+ children: [{ id: "child", type: "paragraph", content: "Body" }],
+ });
+ expect(
+ editor
+ .domElement!.querySelector(".content-frame")
+ ?.getAttribute("data-count"),
+ ).toBe("1");
+ editor.removeBlocks(["child"]);
+ expect(editor.domElement!.querySelector(".content-frame")).toBeNull();
+ expect(editor.domElement!.textContent).toContain("Title");
+ } finally {
+ editor._tiptapEditor.destroy();
+ }
+ });
+
+ it("refreshes vanilla frame chrome when title content changes", () => {
+ const editor = editorWith([
+ {
+ id: "frame",
+ type: "contentFrame",
+ content: "Title",
+ children: [{ id: "child", type: "paragraph", content: "Body" }],
+ },
+ ]);
+ try {
+ editor.updateBlock("frame", { content: "Updated" });
+ expect(
+ editor
+ .domElement!.querySelector(".content-frame")
+ ?.getAttribute("data-title"),
+ ).toContain("Updated");
+ expect(
+ editor.domElement!.querySelector('[data-id="child"]')?.textContent,
+ ).toBe("Body");
+ } finally {
+ editor._tiptapEditor.destroy();
+ }
+ });
+
+ it("mounts children in the slot and patches chrome in place on prop change", () => {
+ const editor = editorWith([
+ {
+ id: "box-0",
+ type: "frameBox",
+ props: { flavor: "warning" },
+ children: [{ id: "box-child", type: "paragraph", content: "Child" }],
+ },
+ ]);
+ try {
+ const root = editor.domElement!;
+ const box = root.querySelector(".frame-box")!;
+ // Non-default props are stamped for the round-trip parse to read.
+ expect(box.getAttribute("data-flavor")).toBe("warning");
+ const slot = box.querySelector(".frame-slot")!;
+ expect(slot.querySelector('[data-id="box-child"]')).not.toBeNull();
+ expect(slot.textContent).toBe("Child");
+
+ editor.updateBlock("box-0", {
+ props: { flavor: "tip" },
+ } as any);
+
+ // The chrome follows the prop change, and the slot element itself is
+ // untouched: the frame patched in place instead of rebuilding. The
+ // author's frame also follows a prop returning to its default.
+ expect(
+ root.querySelector(".frame-box")!.getAttribute("data-flavor"),
+ ).toBe("tip");
+ expect(root.querySelector(".frame-slot")).toBe(slot);
+ expect(slot.querySelector('[data-id="box-child"]')).not.toBeNull();
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ } finally {
+ editor._tiptapEditor.destroy();
+ }
+ });
+});
+
+it("honors a titled block's draggable flag through its regular block wrapper", () => {
+ const editor = editorWith([
+ { id: "locked", type: "contentFrame", content: "Title" },
+ ]);
+ try {
+ const blockSpecs: Record = editor.schema.blockSpecs;
+ const title = editor.domElement!.querySelector(
+ '[data-content-type="contentFrame"]',
+ )!;
+ expect(
+ getDraggableBlockFromElement(
+ title,
+ editor._tiptapEditor.view,
+ (type) => blockSpecs[type].implementation.meta?.draggable !== false,
+ ),
+ ).toBeUndefined();
+ const block = getBlockFromElement(title, editor._tiptapEditor.view);
+ expect(block).toMatchObject({ id: "locked", type: "contentFrame" });
+ expect(block?.node.matches(CONTAINER_SELECTOR)).toBe(false);
+ } finally {
+ editor._tiptapEditor.destroy();
+ }
+});
+
+it("supplies the node-view context to vanilla frames", () => {
+ const contextualFrame = createBlockSpec(
+ { type: "contextualFrame", propSchema: {}, content: "inline" },
+ {
+ render: renderDiv,
+ renderFrame() {
+ expect(this.renderType).toBe("nodeView");
+ expect(this.props?.node.firstChild?.type.name).toBe("contextualFrame");
+ expect(this.blockContentDOMAttributes).toEqual({
+ "data-test": "content",
+ });
+ expect(this.propSchema).toEqual({});
+ const dom = document.createElement("div");
+ dom.className = "contextual-frame";
+ return { dom, slot: dom };
+ },
+ },
+ )();
+ const editor = BlockNoteEditor.create({
+ schema: BlockNoteSchema.create({
+ blockSpecs: { ...defaultBlockSpecs, contextualFrame },
+ }),
+ domAttributes: { blockContent: { "data-test": "content" } },
+ initialContent: [{ type: "contextualFrame", content: "Title" }],
+ });
+ try {
+ editor.mount(document.createElement("div"));
+ expect(
+ editor.domElement?.querySelector(".contextual-frame")?.textContent,
+ ).toBe("Title");
+ } finally {
+ editor._tiptapEditor.destroy();
+ }
+});
+
+it("rebuilds or declines a frame when update returns false and cleans up each instance", () => {
+ const destroy = vi.fn();
+ const lifecycleFrame = createBlockSpec(
+ {
+ type: "lifecycleFrame",
+ propSchema: {
+ mode: { default: "first", values: ["first", "second", "plain"] },
+ },
+ content: "inline",
+ },
+ {
+ render: renderDiv,
+ renderFrame(block) {
+ if (block.props.mode === "plain") {
+ return undefined;
+ }
+ const dom = document.createElement("section");
+ dom.className = "lifecycle-frame";
+ return {
+ dom,
+ slot: dom,
+ destroy,
+ update(updated) {
+ return updated.props.mode === block.props.mode;
+ },
+ };
+ },
+ },
+ )();
+ const editor = BlockNoteEditor.create({
+ schema: BlockNoteSchema.create({
+ blockSpecs: { ...defaultBlockSpecs, lifecycleFrame },
+ }),
+ initialContent: [
+ {
+ id: "frame",
+ type: "lifecycleFrame",
+ content: "Title",
+ children: [{ id: "child", content: "Body" }],
+ },
+ ],
+ });
+ try {
+ editor.mount(document.createElement("div"));
+ const root = editor.domElement!;
+ const first = root.querySelector(".lifecycle-frame");
+ editor.updateBlock("frame", { content: "Updated" });
+ expect(root.querySelector(".lifecycle-frame")).toBe(first);
+ expect(destroy).not.toHaveBeenCalled();
+ editor.updateBlock("frame", { props: { mode: "second" } });
+ expect(root.querySelector(".lifecycle-frame")).not.toBe(first);
+ expect(destroy).toHaveBeenCalledTimes(1);
+ editor.updateBlock("frame", { props: { mode: "plain" } });
+ expect(root.querySelector(".lifecycle-frame")).toBeNull();
+ expect(destroy).toHaveBeenCalledTimes(2);
+ expect(editor.getBlock("frame")!.children[0].id).toBe("child");
+ expect(root.textContent).toContain("Updated");
+ expect(root.textContent).toContain("Body");
+ editor.updateBlock("frame", { props: { mode: "first" } });
+ expect(root.querySelector(".lifecycle-frame")).not.toBeNull();
+ } finally {
+ editor._tiptapEditor.destroy();
+ }
+ expect(destroy).toHaveBeenCalledTimes(3);
+});
+
+it("exports title and children inside the static frame slot with the DOM context", () => {
+ const staticFrame = createBlockSpec(
+ {
+ type: "staticFrame",
+ propSchema: { framed: { default: true } },
+ content: "inline",
+ },
+ {
+ render: renderDiv,
+ renderFrame(block) {
+ expect(this.renderType).toBe("dom");
+ expect(this.props).toBeUndefined();
+ expect(this.blockContentDOMAttributes).toEqual({
+ "data-test": "static",
+ });
+ expect(this.propSchema).toEqual({ framed: { default: true } });
+ if (!block.props.framed) {
+ return undefined;
+ }
+ const dom = document.createElement("section");
+ dom.className = "static-frame";
+ const slot = document.createElement("div");
+ slot.className = "static-slot";
+ dom.append(slot);
+ return { dom, slot };
+ },
+ },
+ )();
+ const editor = BlockNoteEditor.create({
+ schema: BlockNoteSchema.create({
+ blockSpecs: { ...defaultBlockSpecs, staticFrame },
+ }),
+ domAttributes: { blockContent: { "data-test": "static" } },
+ initialContent: [
+ {
+ id: "framed",
+ type: "staticFrame",
+ content: "Title",
+ children: [{ id: "body", content: "Body" }],
+ },
+ {
+ id: "plain",
+ type: "staticFrame",
+ props: { framed: false },
+ content: "Plain",
+ },
+ ],
+ });
+ try {
+ const dom = document.createElement("div");
+ dom.innerHTML = editor.blocksToFullHTML(editor.document);
+ expect(dom.querySelectorAll(".static-frame")).toHaveLength(1);
+ const slot = dom.querySelector(".static-slot")!;
+ expect(slot.querySelector(":scope > .bn-block-content")?.textContent).toBe(
+ "Title",
+ );
+ expect(slot.querySelector(":scope > .bn-block-group")?.textContent).toBe(
+ "Body",
+ );
+ expect(dom.textContent).toContain("Plain");
+ expect(editor.tryParseHTMLToBlocks(dom.innerHTML)).toEqual(editor.document);
+ } finally {
+ editor._tiptapEditor.destroy();
+ }
+});
diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts
index d91ee10f89..f6a06eb73e 100644
--- a/packages/core/src/schema/blocks/types.ts
+++ b/packages/core/src/schema/blocks/types.ts
@@ -1,11 +1,11 @@
/** Define the main block types **/
// import { Extension, Node } from "@tiptap/core";
-import type { Node, NodeViewRendererProps } from "@tiptap/core";
import type {
- Fragment,
- Node as ProsemirrorNode,
- Schema,
-} from "prosemirror-model";
+ Node,
+ NodeViewRenderer,
+ NodeViewRendererProps,
+} from "@tiptap/core";
+import type { Fragment, Node as PMNode, Schema } from "prosemirror-model";
import type { ViewMutationRecord } from "prosemirror-view";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import type {
@@ -67,6 +67,16 @@ export interface BlockConfigMeta<
*/
isolating?: boolean;
+ /**
+ * Whether this block type gets a side menu drag handle (and can be dragged
+ * by it). Applies to any block type, container or not: e.g. a
+ * "locked" block can opt out of dragging entirely. A block that opts out is
+ * skipped when looking for a drag handle, so the handle falls through to the
+ * nearest draggable ancestor.
+ * @default true
+ */
+ draggable?: boolean;
+
/**
* Enables syntax highlighting of the contents of the block with the result of this callback
*/
@@ -81,9 +91,49 @@ export interface BlockConfigMeta<
}
/**
- * BlockConfig contains the "schema" info about a Block type
- * i.e. what props it supports, what content it supports, etc.
+ * The type name of a container block, as used in {@link ChildrenConfig.allow}.
+ */
+export type AllowedChildType = string;
+
+/**
+ * What may appear as a child of a container block.
+ *
+ * - `"blocks"`: any regular block, or any container block placeable anywhere.
+ * This cannot be narrowed to specific regular block types: every regular
+ * block is the *same* ProseMirror node (`blockContainer`), so paragraphs,
+ * headings and code blocks are indistinguishable at the node level.
+ * - `readonly AllowedChildType[]`: only these container types, enforced exactly
+ * by the schema. Naming a regular block type is a startup error; per-type
+ * regular-block filtering can be added to this same form later, with no API
+ * change.
+ *
+ * Neither form includes `placeable: "namedOnly"` types. Those appear only
+ * where a parent names them explicitly in an array.
+ */
+export type ChildrenAllow = "blocks" | readonly AllowedChildType[];
+
+/**
+ * Marks a block as a *container*: a block whose body is other blocks, exposed
+ * as `block.children` at runtime.
+ *
+ * The config describes one uniform body, semantically a single implicit
+ * slot. Ordered multi-slot bodies (a `sequence` of slots) can be added later
+ * as a sibling form.
*/
+export type ChildrenConfig = {
+ /** What may appear as a child. See {@link ChildrenAllow}. */
+ allow: ChildrenAllow;
+ /**
+ * How few children the container may hold. When children drop below the
+ * minimum, a container that can stand anywhere dissolves into its
+ * surviving children (a one-column column list is just those blocks), and
+ * one that only exists inside another container is topped back up with
+ * empty children (a column keeps existing).
+ * @default 1
+ */
+ min?: number;
+};
+
export interface BlockConfig<
T extends string = string,
PS extends PropSchema = PropSchema,
@@ -106,8 +156,28 @@ export interface BlockConfig<
* The content that the block supports
*/
content: C;
- // TODO: how do you represent things that have nested content?
- // e.g. tables, alerts (with title & content)
+ /**
+ * Declares owned child blocks, exposed on `block.children`.
+ * With `content: "none"`, the block is a pure container whose `render`
+ * mounts children through contentDOM (React: contentRef).
+ * With `content: "inline"` or `"plain"`, `children: { allow: "blocks" }`
+ * gives the block owned children below its own text. These children remain
+ * optional; their types and minimum count cannot be restricted.
+ * `renderFrame` independently styles the block's content and children.
+ */
+ children?: ChildrenConfig;
+ /**
+ * Where this block may be placed.
+ *
+ * - `"anywhere"` (default): anywhere a regular block goes, the document
+ * root or nested under any other block.
+ * - `"namedOnly"`: only inside a container that names this type in its
+ * `children.allow` array (e.g. a `column` inside a `columnList`).
+ *
+ * Only meaningful for container blocks; regular blocks are always placeable
+ * anywhere.
+ */
+ placeable?: "anywhere" | "namedOnly";
}
declare module "prosemirror-model" {
@@ -224,7 +294,7 @@ export type LooseBlockSpec<
config: BlockConfig;
implementation: Omit<
BlockImplementation,
- "render" | "toExternalHTML"
+ "render" | "renderFrame" | "toExternalHTML"
> & {
// purposefully stub the types for render and toExternalHTML since they reference the block
render: (
@@ -240,9 +310,21 @@ export type LooseBlockSpec<
dom: HTMLElement | DocumentFragment;
contentDOM?: HTMLElement;
ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
- update?: (node: ProsemirrorNode) => boolean;
destroy?: () => void;
+ update?: (node: PMNode) => boolean | void;
};
+ renderFrame?: (
+ block: any,
+ editor: BlockNoteEditor,
+ ) =>
+ | {
+ dom: HTMLElement | DocumentFragment;
+ slot: HTMLElement;
+ /** Releases resources when the live frame is replaced or destroyed. */
+ destroy?: () => void;
+ update?: (block: any) => boolean | void;
+ }
+ | undefined;
toExternalHTML?: (
block: any,
editor: BlockNoteEditor,
@@ -283,7 +365,7 @@ export type BlockSpecs = {
config: BlockSpec["config"];
implementation: Omit<
BlockSpec["implementation"],
- "render" | "toExternalHTML"
+ "render" | "renderFrame" | "toExternalHTML"
> & {
// purposefully stub the types for render and toExternalHTML since they reference the block
render: (
@@ -299,9 +381,21 @@ export type BlockSpecs = {
dom: HTMLElement | DocumentFragment;
contentDOM?: HTMLElement;
ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
- update?: (node: ProsemirrorNode) => boolean;
destroy?: () => void;
+ update?: (node: PMNode) => boolean | void;
};
+ renderFrame?: (
+ block: any,
+ editor: BlockNoteEditor,
+ ) =>
+ | {
+ dom: HTMLElement | DocumentFragment;
+ slot: HTMLElement;
+ /** Releases resources when the live frame is replaced or destroyed. */
+ destroy?: () => void;
+ update?: (block: any) => boolean | void;
+ }
+ | undefined;
toExternalHTML?: (
block: any,
editor: BlockNoteEditor,
@@ -566,12 +660,14 @@ export type BlockImplementation<
| "table"
| "plain",
> = {
+ /** @internal Framework adapter for the outer blockContainer node view. */
+ frameNodeView?: NodeViewRenderer;
/**
* Metadata
*/
meta?: BlockConfigMeta;
/**
- * A function that converts the block into a DOM element
+ * A function that converts the block into a DOM element.
*/
render: (
this:
@@ -603,20 +699,79 @@ export type BlockImplementation<
dom: HTMLElement | DocumentFragment;
contentDOM?: HTMLElement;
ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
+ destroy?: () => void;
/**
- * Called by ProseMirror when this block's node is updated (e.g. its content
- * or props change). Return `true` to handle the update in place - keeping
- * the existing DOM - or `false` to have the node view recreated via
- * `render`. When omitted, ProseMirror keeps the node view and reconciles its
- * `contentDOM` in place as long as the node type stays the same.
+ * Optional NodeView update hook. Called when the underlying ProseMirror
+ * node's attributes change (or its decorations change). Return `false` to
+ * tell ProseMirror to destroy and recreate the NodeView (i.e. re-run
+ * `render` from scratch). Return `true` (or `undefined`) when you have
+ * patched `dom` in-place and PM should keep the existing view.
*
- * Useful for blocks whose `render` builds custom DOM that needs to stay in
- * sync with the node (e.g. a code block rendering a preview of its content).
+ * Only honored for container blocks (blocks with `children`), where
+ * recreating the node view would remount every child block: e.g. column
+ * resizing patches widths in place through this hook. Non-container
+ * blocks always recreate on attr changes (see
+ * https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464).
*/
- update?: (node: ProsemirrorNode) => boolean;
- destroy?: () => void;
+ update?: (node: PMNode) => boolean | void;
};
+ /**
+ * Draws the chrome *around* a block's content and children: the author's
+ * markup wraps both, and the `slot` is where BlockNote mounts them.
+ *
+ * The slot holds the content first and the children after it. A pure
+ * container already owns its outer DOM through `render`.
+ *
+ * `render` stays the knob for the block's own content. A block may use
+ * both: `render` draws the title, `renderFrame` draws the box around title
+ * and body. Returning `undefined` declines — the block renders plain — so
+ * a block can decide from its props, content, or children whether it is framed.
+ *
+ * Chrome outside the slot is the author's: ProseMirror leaves its events
+ * alone. An `update` hook receives the current block on updates and patches
+ * the frame in place. Return `false` to rebuild (or decline) the frame.
+ * Without an update hook, block changes rebuild the frame.
+ */
+ renderFrame?: (
+ this:
+ | Record
+ | ({
+ blockContentDOMAttributes: Record;
+ propSchema?: TProps;
+ } & (
+ | {
+ renderType: "nodeView";
+ props: NodeViewRendererProps;
+ }
+ | {
+ renderType: "dom";
+ props: undefined;
+ }
+ )),
+ block: BlockFromConfig, any, any>,
+ editor: BlockNoteEditor<
+ Record>,
+ I,
+ S
+ >,
+ ) =>
+ | {
+ dom: HTMLElement | DocumentFragment;
+ /** Where BlockNote mounts the block's content and/or children. */
+ slot: HTMLElement;
+ /** Releases resources when the live frame is replaced or destroyed. */
+ destroy?: () => void;
+ update?: (
+ block: BlockFromConfig<
+ BlockConfig,
+ any,
+ any
+ >,
+ ) => boolean | void;
+ }
+ | undefined;
+
/**
* Exports block to external HTML. If not defined, the output will be the same
* as `render(...).dom`.
@@ -711,4 +866,4 @@ export type CustomBlockImplementation<
T extends string = string,
PS extends PropSchema = PropSchema,
C extends "inline" | "none" | "plain" = "inline" | "none" | "plain",
-> = BlockImplementation;
+> = Omit, "frameNodeView">;
diff --git a/packages/core/src/schema/blocks/validateChildren.ts b/packages/core/src/schema/blocks/validateChildren.ts
new file mode 100644
index 0000000000..5525dc8cc5
--- /dev/null
+++ b/packages/core/src/schema/blocks/validateChildren.ts
@@ -0,0 +1,66 @@
+import { isContainerConfig } from "./children.js";
+import type { BlockConfig } from "./types.js";
+
+/** Reject declarations ProseMirror would accept with different semantics. */
+export function validateChildrenConfigs(
+ blockSpecs: Record<
+ string,
+ { config: Pick }
+ >,
+) {
+ for (const [type, { config }] of Object.entries(blockSpecs)) {
+ if (config.placeable === "namedOnly" && !isContainerConfig(config)) {
+ fail(
+ type,
+ '`placeable: "namedOnly"` requires a container node; regular blocks share the same wrapper and cannot restrict their placement.',
+ );
+ }
+ if (!config.children) {
+ continue;
+ }
+
+ const { allow, min } = config.children;
+ if (config.content === "table") {
+ fail(type, "`children` is not supported on table blocks.");
+ }
+
+ // Text blocks share an optional child group, so only pure containers
+ // can restrict their children's types or minimum count.
+ if (
+ config.content !== "none" &&
+ (allow !== "blocks" || min !== undefined)
+ ) {
+ fail(
+ type,
+ 'blocks with inline or plain content support `children: { allow: "blocks" }` only. Child-type and minimum-count restrictions require a pure container.',
+ );
+ }
+
+ // Every regular block is the same node (`blockContainer`), so naming one
+ // here compiles to a valid schema that restricts nothing.
+ if (allow !== "blocks") {
+ for (const allowed of allow) {
+ if (!Object.prototype.hasOwnProperty.call(blockSpecs, allowed)) {
+ fail(
+ type,
+ `\`allow\` contains "${allowed}", which is not a configured block type.`,
+ );
+ }
+ if (!isContainerConfig(blockSpecs[allowed].config)) {
+ fail(
+ type,
+ `\`allow\` contains "${allowed}", which is a regular block, not a container block. ` +
+ "Restricting which regular block types a container accepts is not yet supported, as every regular block is the same ProseMirror node. " +
+ 'Use `allow: "blocks"` to accept all regular blocks, or name only container block types.',
+ );
+ }
+ }
+ }
+ }
+}
+
+function fail(type: string, message: string): never {
+ throw new Error(
+ `Invalid \`children\` config for block "${type}": ${message}`,
+ );
+}
diff --git a/packages/core/src/schema/schema.ts b/packages/core/src/schema/schema.ts
index a9c9f814d3..7b0ffde4bf 100644
--- a/packages/core/src/schema/schema.ts
+++ b/packages/core/src/schema/schema.ts
@@ -16,6 +16,7 @@ import {
getInlineContentSchemaFromSpecs,
getStyleSchemaFromSpecs,
} from "./index.js";
+import { validateChildrenConfigs } from "./blocks/validateChildren.js";
function removeUndefined | undefined>(obj: T): T {
if (!obj) {
@@ -91,6 +92,10 @@ export class CustomBlockNoteSchema<
})),
);
+ // Validation runs before the nodes are built, so the misconfigurations
+ // ProseMirror cannot report on its own surface as clear errors.
+ validateChildrenConfigs(this.opts.blockSpecs);
+
const blockSpecs = Object.fromEntries(
Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => {
return [
diff --git a/packages/core/src/yjs/extensions/FixUpSchema.ts b/packages/core/src/yjs/extensions/FixUpSchema.ts
index 37fb1fd4e9..42234c9b45 100644
--- a/packages/core/src/yjs/extensions/FixUpSchema.ts
+++ b/packages/core/src/yjs/extensions/FixUpSchema.ts
@@ -25,6 +25,12 @@ export const FixUpSchemaExtension = createExtension(({ editor }) => {
// create a copy that we can mutate (otherwise, assigning attrs is not safe and corrupts the pm state)
const jsonNode = JSON.parse(JSON.stringify(ret.toJSON()));
+ // The first fill of the doc's blockGroup is always a `blockContainer`:
+ // container block nodes are clamped below its priority
+ // (`containerNodePriority`) precisely so auto-fill picks it first. If
+ // that ever stops holding, throwing here is better than silently
+ // leaving the id unset, which would let every peer generate its own
+ // initial block id.
jsonNode.content[0].content[0].attrs.id = "initialBlockId";
cache = Node.fromJSON(schema, jsonNode);
diff --git a/packages/dev-scripts/examples/template-react/vite.config.ts.template.tsx b/packages/dev-scripts/examples/template-react/vite.config.ts.template.tsx
index 7b6f0af639..c6aefb8117 100644
--- a/packages/dev-scripts/examples/template-react/vite.config.ts.template.tsx
+++ b/packages/dev-scripts/examples/template-react/vite.config.ts.template.tsx
@@ -1,6 +1,5 @@
import type { Project } from "../util";
-// TODO: the ../../ paths are broken
const template = (
project: Project,
) => `// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
@@ -23,7 +22,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
@@ -34,11 +33,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
- "../../packages/core/src/"
+ "../../../packages/core/src/"
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/"
+ "../../../packages/react/src/"
),
} as any),
},
diff --git a/packages/react/src/components/Popovers/BlockPopover.tsx b/packages/react/src/components/Popovers/BlockPopover.tsx
index 2bf0e4fa57..a61d60c4ee 100644
--- a/packages/react/src/components/Popovers/BlockPopover.tsx
+++ b/packages/react/src/components/Popovers/BlockPopover.tsx
@@ -1,4 +1,4 @@
-import { getNodeById } from "@blocknote/core";
+import { getNodeById, isContainerNode } from "@blocknote/core";
import { ReactNode, useMemo } from "react";
import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
@@ -29,6 +29,34 @@ export const BlockPopover = (
return undefined;
}
+ // Containers anchor to their own root, not the child-block contentDOM.
+ if (isContainerNode(nodePosInfo.node.type)) {
+ const dom = editor.prosemirrorView.nodeDOM(nodePosInfo.posBeforeNode);
+ if (dom instanceof Element) {
+ // React adds two display:contents wrappers around the author root.
+ const root = dom.matches(".bn-container-node-view")
+ ? dom.querySelector(":scope > [data-node-view-wrapper]")
+ ?.firstElementChild
+ : dom;
+ return { element: root ?? dom };
+ }
+ }
+
+ // A frame's editable slot may start after interactive chrome, such as
+ // a toggle button. Anchor outside the whole block so the side menu
+ // does not cover that chrome. The blockContainer node view owns a
+ // boxed outer element, even when the frame returns a fragment.
+ const contentType = nodePosInfo.node.firstChild?.type.name;
+ if (
+ contentType &&
+ editor.schema.blockSpecs[contentType]?.implementation.renderFrame
+ ) {
+ const dom = editor.prosemirrorView.nodeDOM(nodePosInfo.posBeforeNode);
+ if (dom instanceof Element) {
+ return { element: dom };
+ }
+ }
+
const { node } = editor.prosemirrorView.domAtPos(
nodePosInfo.posBeforeNode + 1,
);
diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css
index 507f2cd46f..c3a39005a5 100644
--- a/packages/react/src/editor/styles.css
+++ b/packages/react/src/editor/styles.css
@@ -111,6 +111,13 @@
width: 100%;
}
+/* Container blocks own their outer DOM: the block's root element is the one
+ its `render` returned, so the wrapper React needs around it must not be a
+ box of its own. */
+.bn-react-node-view-renderer.bn-container-node-view {
+ display: contents;
+}
+
/* Indent line styling */
.bn-block-group
.bn-block:not(:has(.bn-toggle-wrapper))
diff --git a/packages/react/src/schema/@util/ReactRenderUtil.ts b/packages/react/src/schema/@util/ReactRenderUtil.ts
index dac1a68a86..5ecc4ca6f3 100644
--- a/packages/react/src/schema/@util/ReactRenderUtil.ts
+++ b/packages/react/src/schema/@util/ReactRenderUtil.ts
@@ -36,9 +36,9 @@ export function renderToDOMSpec(
}
if (!div.childElementCount) {
- // TODO
- // eslint-disable-next-line no-console
- console.warn("ReactInlineContentSpec: renderHTML() failed");
+ // A conditional frame may render null. Dispose its effects even when
+ // there is no DOM to clone, just as on the non-empty path below.
+ root?.unmount();
return {
dom: document.createElement("span"),
};
diff --git a/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx b/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx
new file mode 100644
index 0000000000..4c29c09a15
--- /dev/null
+++ b/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx
@@ -0,0 +1,228 @@
+import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core";
+import { useState } from "react";
+import { userEvent } from "vite-plus/test/browser";
+import { flushSync } from "react-dom";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it } from "vite-plus/test";
+
+import { BlockPopover } from "../components/Popovers/BlockPopover.js";
+import { BlockNoteViewRaw } from "../editor/BlockNoteView.js";
+import { createReactBlockSpec } from "./ReactBlockSpec.js";
+
+/**
+ * Tests for React container blocks in a real browser.
+ *
+ * Everything here needs a real DOM: the external-HTML path renders the block
+ * through a temporary `createRoot` (see `@util/ReactRenderUtil`), and a React
+ * node view only runs once `contentComponent` is set, which happens when
+ * `BlockNoteViewRaw` mounts the editor. Document-model behaviour of
+ * containers in general is covered by the core suites in
+ * `api/blockManipulation/containers/`.
+ */
+
+// A container: its `contentRef` element holds its child blocks.
+const createCallout = createReactBlockSpec(
+ {
+ type: "callout",
+ propSchema: { flavor: { default: "tip" } },
+ content: "none",
+ children: { allow: "blocks" },
+ },
+ {
+ render: function Callout(props) {
+ const [alternate, setAlternate] = useState(false);
+ const Tag = alternate ? "section" : "div";
+ return (
+
+
+
+
+ );
+ },
+ },
+);
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ callout: createCallout(),
+ },
+});
+
+describe("React container block external HTML", () => {
+ it("serializes the author's own root element, unwrapped", () => {
+ const editor = BlockNoteEditor.create({ schema });
+
+ const html = editor.blocksToHTMLLossy([
+ {
+ type: "callout",
+ id: "c-0",
+ children: [{ id: "c-p-0", type: "paragraph", content: "Hello" }],
+ },
+ ] as any);
+
+ // Container blocks own their outer DOM entirely. Regression test for the
+ // React `toExternalHTML` path wrapping them in a spurious
+ // `bn-block-content` div (core's `createBlockSpec` passes them through).
+ // The root is the element `render` returned, with no React wrapper in
+ // between, so `.callout[data-*]` CSS matches it here exactly as in the
+ // live editor.
+ expect(html).not.toContain('data-content-type="callout"');
+ expect(html).not.toContain("data-node-view-wrapper");
+ expect(html).toContain('class="callout"');
+ expect(html).toContain('data-node-type="callout"');
+ expect(html).toContain("Hello");
+
+ editor._tiptapEditor.destroy();
+ });
+});
+
+let root: Root | undefined;
+let div: HTMLDivElement | undefined;
+let editor: BlockNoteEditor | undefined;
+
+afterEach(() => {
+ root?.unmount();
+ root = undefined;
+ if (div) {
+ document.body.removeChild(div);
+ div = undefined;
+ }
+ editor?._tiptapEditor.destroy();
+ editor = undefined;
+});
+
+/** Lets TipTap's deferred node-view render and React's commit run. */
+const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+async function mountEditor(initialContent: any[]) {
+ div = document.createElement("div");
+ document.body.appendChild(div);
+
+ editor = BlockNoteEditor.create({
+ schema,
+ trailingBlock: false,
+ initialContent,
+ }) as BlockNoteEditor;
+
+ root = createRoot(div);
+ flushSync(() => {
+ root!.render( );
+ });
+ // TipTap only renders a node view synchronously when this is set; BlockNote
+ // mounts the editor itself and never does, so the first batch of node views
+ // takes the deferred path (see `tests/src/unit/react/staleNodeViewPos.test.tsx`).
+ (editor as any)._tiptapEditor.isEditorContentInitialized = true;
+ await tick();
+
+ return { editor: editor!, div: div! };
+}
+
+describe("React container block node view", () => {
+ it("anchors a container popover to the author's box", async () => {
+ const { editor, div } = await mountEditor([
+ {
+ id: "outer",
+ type: "callout",
+ children: [{ id: "inner", type: "callout" }],
+ },
+ ]);
+ const box = div.querySelector(".callout")!;
+ let anchor: Element | undefined;
+ flushSync(() => {
+ root!.render(
+
+ {};
+ },
+ }}
+ >
+ Container menu
+
+ ,
+ );
+ });
+ await expect.poll(() => anchor).toBe(box);
+ expect(box.getBoundingClientRect().height).toBeGreaterThan(0);
+ });
+
+ it("keeps attributes and native editing after a local state root swap", async () => {
+ const { editor, div } = await mountEditor([
+ {
+ id: "c-0",
+ type: "callout",
+ props: { flavor: "warning" },
+ children: [{ id: "child", type: "paragraph", content: "Body" }],
+ },
+ ]);
+ const child = div.querySelector('[data-id="child"]');
+ await userEvent.click(div.querySelector(".callout button")!);
+ const box = div.querySelector("section.callout")!;
+ expect(box.getAttribute("data-id")).toBe("c-0");
+ expect(box.getAttribute("data-node-type")).toBe("callout");
+ expect(box.getAttribute("data-flavor")).toBe("warning");
+ expect(box.querySelector('[data-id="child"]')).toBe(child);
+ editor.focus();
+ editor.setTextCursorPosition("child", "end");
+ await userEvent.keyboard("!");
+ expect(child?.textContent).toBe("Body!");
+ expect(editor.getTextCursorPosition().block.id).toBe("child");
+ });
+
+ it("stamps only non-default props onto the block's own root, and keeps them in sync", async () => {
+ const mounted = await mountEditor([
+ { id: "c-0", type: "callout", children: [{ type: "paragraph" }] },
+ ]);
+
+ const calloutRoot = mounted.div.querySelector(".callout")!;
+ // The author's element, not `div.react-renderer` or the node view
+ // wrapper: exactly the class the author wrote, and nothing else.
+ expect(calloutRoot.className).toBe("callout");
+ expect(calloutRoot.getAttribute("data-id")).toBe("c-0");
+ // `flavor` is at its default, so no attribute is written for it.
+ expect(calloutRoot.hasAttribute("data-flavor")).toBe(false);
+
+ mounted.editor.updateBlock("c-0", { props: { flavor: "warning" } } as any);
+ await tick();
+
+ // Re-queried: a prop change must land on whatever element is now the
+ // block's root, so `.callout[data-flavor="warning"]` selects in the live
+ // editor exactly as it does in the serialized HTML above.
+ expect(
+ mounted.div
+ .querySelector(".callout")!
+ .getAttribute("data-flavor"),
+ ).toBe("warning");
+ });
+
+ it("mounts a pure container's children inside its `contentRef` element", async () => {
+ const mounted = await mountEditor([
+ {
+ id: "c-0",
+ type: "callout",
+ children: [{ id: "c-child", type: "paragraph", content: "Child" }],
+ },
+ ]);
+
+ const body = mounted.div.querySelector(".callout-body")!;
+ // A container with no content of its own puts its children where the
+ // author placed `contentRef`, not somewhere else in the node view. The
+ // child's own block element is a descendant, so this checks structure,
+ // not just text that happened to bubble up.
+ expect(body.querySelector('[data-id="c-child"]')).not.toBeNull();
+ expect(body.textContent).toBe("Child");
+ });
+});
diff --git a/packages/react/src/schema/ReactBlockSpec.frame.browser.test.tsx b/packages/react/src/schema/ReactBlockSpec.frame.browser.test.tsx
new file mode 100644
index 0000000000..09208b7915
--- /dev/null
+++ b/packages/react/src/schema/ReactBlockSpec.frame.browser.test.tsx
@@ -0,0 +1,173 @@
+import {
+ BlockNoteEditor,
+ BlockNoteSchema,
+ createBlockSpec,
+} from "@blocknote/core";
+import { userEvent } from "vite-plus/test/browser";
+import { useState } from "react";
+import { flushSync } from "react-dom";
+import { createRoot } from "react-dom/client";
+import { expect, it, vi } from "vite-plus/test";
+
+import { BlockNoteViewRaw } from "../editor/BlockNoteView.js";
+import { createReactBlockSpec } from "./ReactBlockSpec.js";
+
+function createFrameSchema(content: "inline" | "plain") {
+ return BlockNoteSchema.create().extend({
+ blockSpecs: {
+ vanilla: createBlockSpec(
+ { type: "vanilla", propSchema: {}, content },
+ {
+ render() {
+ const dom = document.createElement("div");
+ return { dom, contentDOM: dom };
+ },
+ renderFrame(block) {
+ const dom = document.createElement("section");
+ dom.className = "vanilla-frame";
+ dom.dataset.title = JSON.stringify(block.content);
+ const slot = document.createElement("div");
+ dom.append(slot);
+ return { dom, slot };
+ },
+ },
+ )(),
+ framed: createReactBlockSpec(
+ {
+ type: "framed",
+ propSchema: { framed: { default: true } },
+ content,
+ children: { allow: "blocks" },
+ },
+ {
+ render: (props) => (
+
+ ),
+ renderFrame: function Frame(props) {
+ const [clicks, setClicks] = useState(0);
+ if (!props.block.props.framed) {
+ return null;
+ }
+ return (
+
+
+
+
+ );
+ },
+ },
+ )(),
+ },
+ });
+}
+
+it.each(["inline", "plain"] as const)(
+ "keeps native %s editing and selection working across frame changes",
+ async (content) => {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ const editor = BlockNoteEditor.create({
+ schema: createFrameSchema(content),
+ trailingBlock: false,
+ initialContent: [
+ {
+ id: "frame",
+ type: "framed",
+ content: "Title",
+ children: [{ id: "body", type: "paragraph", content: "Body" }],
+ },
+ ],
+ });
+ try {
+ flushSync(() => root.render( ));
+ await vi.waitFor(() =>
+ expect(host.querySelector(".frame-body")?.textContent).toBe(
+ "TitleBody",
+ ),
+ );
+ const child = host.querySelector('[data-id="body"]');
+
+ editor.focus();
+ editor.setTextCursorPosition("frame", "end");
+ await userEvent.keyboard("!");
+ await vi.waitFor(() =>
+ expect(host.querySelector(".frame-title")?.textContent).toBe("Title!"),
+ );
+ await userEvent.click(host.querySelector("button")!);
+ await vi.waitFor(() =>
+ expect(host.querySelector("button")?.textContent).toBe("1"),
+ );
+
+ editor.focus();
+ editor.setTextCursorPosition("body", "end");
+ editor.updateBlock("frame", { props: { framed: false } });
+ await vi.waitFor(() => expect(host.querySelector(".frame")).toBeNull());
+ await userEvent.keyboard("?");
+ await vi.waitFor(() => expect(child?.textContent).toBe("Body?"));
+
+ editor.updateBlock("frame", { props: { framed: true } });
+ await vi.waitFor(() =>
+ expect(host.querySelector(".frame-body")?.textContent).toBe(
+ "Title!Body?",
+ ),
+ );
+ await userEvent.keyboard("!");
+ await vi.waitFor(() => expect(child?.textContent).toBe("Body?!"));
+ expect(host.querySelector('[data-id="body"]')).toBe(child);
+ expect(editor.getTextCursorPosition().block.id).toBe("body");
+ } finally {
+ root.unmount();
+ editor._tiptapEditor.destroy();
+ host.remove();
+ }
+ },
+);
+
+it.each(["inline", "plain"] as const)(
+ "keeps native %s typing and selection while vanilla frames refresh",
+ async (content) => {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const editor = BlockNoteEditor.create({
+ schema: createFrameSchema(content),
+ trailingBlock: false,
+ initialContent: [
+ {
+ id: "frame",
+ type: "vanilla",
+ content: "Title",
+ children: [{ id: "body", type: "paragraph", content: "Body" }],
+ },
+ ],
+ });
+ try {
+ editor.mount(host);
+ editor.focus();
+ editor.setTextCursorPosition("frame", "end");
+ await userEvent.keyboard("abc");
+ await vi.waitFor(() =>
+ expect(
+ host.querySelector(".vanilla-frame")?.getAttribute("data-title"),
+ ).toContain("Titleabc"),
+ );
+ expect(editor.getTextCursorPosition().block.id).toBe("frame");
+ editor.setTextCursorPosition("body", "end");
+ await userEvent.keyboard("xyz");
+ await vi.waitFor(() =>
+ expect(host.querySelector('[data-id="body"]')?.textContent).toBe(
+ "Bodyxyz",
+ ),
+ );
+ expect(editor.getTextCursorPosition().block.id).toBe("body");
+ } finally {
+ editor._tiptapEditor.destroy();
+ host.remove();
+ }
+ },
+);
diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx
index 5311d4e37d..dc10df47af 100644
--- a/packages/react/src/schema/ReactBlockSpec.tsx
+++ b/packages/react/src/schema/ReactBlockSpec.tsx
@@ -1,16 +1,21 @@
import {
+ applyContainerAttributes,
+ isContainerConfig,
BlockConfig,
+ BlockFromConfig,
BlockConfigOrCreator,
BlockImplementation,
BlockNoDefaults,
BlockNoteEditor,
BlockSpec,
camelToDataKebab,
+ ChildrenConfig,
CustomBlockImplementation,
Extension,
ExtensionFactoryInstance,
ExtractBlockConfigFromConfigOrCreator,
mergeCSSClasses,
+ nodeToBlock,
Props,
PropSchema,
} from "@blocknote/core";
@@ -20,12 +25,17 @@ import {
ReactNodeViewRenderer,
useReactNodeView,
} from "@tiptap/react";
-import { FC, ReactNode } from "react";
+import { CSSProperties, FC, ReactNode, useCallback, useRef } from "react";
import { renderToDOMSpec } from "./@util/ReactRenderUtil.js";
import { useNodeViewBlock } from "./useNodeViewBlock.js";
// this file is mostly analogoues to `customBlocks.ts`, but for React blocks
+// A container block's root element is the block's own element, so every
+// wrapper React puts above it has to contribute no box of its own. Module
+// scope so the style object is referentially stable across renders.
+const DISPLAY_CONTENTS: CSSProperties = { display: "contents" };
+
export type ReactCustomBlockRenderProps<
B extends BlockConfigOrCreator,
Config extends ExtractBlockConfigFromConfigOrCreator =
@@ -33,11 +43,31 @@ export type ReactCustomBlockRenderProps<
> = {
block: BlockNoDefaults, any, any>;
editor: BlockNoteEditor, any, any>;
-} & (Config["content"] extends "inline" | "plain"
- ? {
- contentRef: (node: HTMLElement | null) => void;
- }
- : object);
+ // A block gets a `contentRef` for its `render` to mount its editable region:
+ // its inline content, or, for a container, its child blocks. Only a
+ // `content: "none"` block without `children` (and the table block, whose
+ // content is managed separately) has nothing to place.
+} & (Config extends { children: ChildrenConfig }
+ ? { contentRef: (node: HTMLElement | null) => void }
+ : Config["content"] extends "inline" | "plain"
+ ? { contentRef: (node: HTMLElement | null) => void }
+ : object);
+
+// extend BlockConfig but use a React render function
+export type ReactCustomBlockFrameProps<
+ B extends BlockConfigOrCreator,
+ Config extends ExtractBlockConfigFromConfigOrCreator =
+ ExtractBlockConfigFromConfigOrCreator,
+> = {
+ block: BlockFromConfig;
+ editor: BlockNoteEditor, any, any>;
+ // A frame gets a `contentRef` for its slot: the mount for the block's
+ // children, or for its content and children together when the block is a
+ // titled block (content of its own plus `children`). Attach it with
+ // `ref={contentRef}` on the slot element, the same way `render` mounts
+ // its editable region.
+ contentRef: (node: HTMLElement | null) => void;
+};
// extend BlockConfig but use a React render function
export type ReactCustomBlockImplementation<
@@ -50,9 +80,13 @@ export type ReactCustomBlockImplementation<
Config["propSchema"],
Config["content"]
>,
- "render" | "toExternalHTML"
+ "render" | "renderFrame" | "toExternalHTML"
> & {
render: FC>;
+ // The outer block node view renders this component live. Its slot holds
+ // the existing content node followed by the child blockGroup, regardless
+ // of whether those children are owned or ordinary nesting.
+ renderFrame?: FC>;
toExternalHTML?: FC<
ReactCustomBlockRenderProps & {
context: {
@@ -131,20 +165,20 @@ export function createReactBlockSpec<
const TName extends string,
const TProps extends PropSchema,
const TContent extends "inline" | "none" | "plain",
+ // Inferred from the config object itself rather than widened to
+ // `BlockConfig<...>`, so `children` survives into the render props and
+ // `contentRef` is offered exactly when the block has an editable region.
+ const BlockConf extends BlockConfig,
const TOptions extends Record | undefined = undefined,
>(
- blockConfigOrCreator: BlockConfig,
+ blockConfigOrCreator: BlockConf,
blockImplementationOrCreator:
- | ReactCustomBlockImplementation>
+ | ReactCustomBlockImplementation
| (TOptions extends undefined
- ? () => ReactCustomBlockImplementation<
- BlockConfig
- >
+ ? () => ReactCustomBlockImplementation
: (
options: Partial,
- ) => ReactCustomBlockImplementation<
- BlockConfig
- >),
+ ) => ReactCustomBlockImplementation),
extensionsOrCreator?:
| (ExtensionFactoryInstance | Extension)[]
| (TOptions extends undefined
@@ -152,7 +186,13 @@ export function createReactBlockSpec<
: (
options: Partial,
) => (ExtensionFactoryInstance | Extension)[]),
-): (options?: Partial) => BlockSpec;
+): (
+ options?: Partial,
+) => BlockSpec<
+ BlockConf["type"],
+ BlockConf["propSchema"],
+ BlockConf["content"]
+>;
export function createReactBlockSpec<
const TName extends string,
const TProps extends PropSchema,
@@ -219,48 +259,148 @@ export function createReactBlockSpec<
? blockImplementationOrCreator(options as any)
: blockImplementationOrCreator;
+ if (!blockImplementation.render) {
+ throw new Error(`Block "${blockConfig.type}" must declare \`render\`.`);
+ }
+
+ const { renderFrame: reactRenderFrame, ...coreImplementation } =
+ blockImplementation;
+
const extensions = extensionsOrCreator
? typeof extensionsOrCreator === "function"
? extensionsOrCreator(options as any)
: extensionsOrCreator
: undefined;
+ // Container-ness is fixed per spec, so every render path can decide once.
+ // A titled block (content of its own plus `children`) keeps its ordinary
+ // shape: only a contentless block builds a container node, so only one
+ // takes the container node view. The titled block's content node renders
+ // through the regular node view; core installs its frame at the
+ // `blockContainer` level (see the `renderFrame` adapter below).
+ const isContainer = isContainerConfig(blockConfig);
+
+ // Shared by the two paths that render to plain DOM (`toExternalHTML` and
+ // `render` outside a node view). A container block's output is the
+ // block's root element, with no wrapper: the attributes core stamps
+ // afterwards then land on the author's own element, the same element they
+ // land on in the live editor.
+ function renderStatic(args: {
+ BlockContent: FC;
+ block: any;
+ editor: any;
+ domAttributes?: Record;
+ isFileBlock?: boolean;
+ context?: any;
+ }) {
+ const { BlockContent, block, editor } = args;
+
+ return renderToDOMSpec((refCB) => {
+ const content = (
+ {
+ refCB(element);
+ if (element && !isContainer) {
+ element.className = mergeCSSClasses(
+ "bn-inline-content",
+ element.className,
+ );
+ }
+ }}
+ context={args.context}
+ />
+ );
+
+ return isContainer ? (
+ content
+ ) : (
+
+ {content}
+
+ );
+ }, editor);
+ }
+
+ const Frame = reactRenderFrame;
+
+ function FrameNodeView(props: NodeViewProps) {
+ // This view belongs to blockContainer itself, so its node is the block.
+ const block = nodeToBlock(props.node, props.view.state.doc);
+ if (block.type !== blockConfig.type) {
+ throw new Error(
+ `Frame for "${blockConfig.type}" received block "${block.type}".`,
+ );
+ }
+ const mountContent = useReactNodeView().nodeViewContentRef;
+ const wrapper = useRef(null);
+ const slot = useRef(null);
+ if (!mountContent || !Frame) {
+ throw new Error("Frame node view requires a frame and content mount.");
+ }
+ const contentRef = useCallback(
+ (element: HTMLElement | null) => {
+ slot.current = element;
+ if (element) {
+ element.dataset.nodeViewContent = "";
+ }
+ // TipTap owns contentDOM and preserves it as a conditional frame
+ // switches between author markup and the default wrapper.
+ mountContent(element ?? wrapper.current);
+ },
+ [mountContent],
+ );
+ const wrapperRef = useCallback(
+ (element: HTMLDivElement | null) => {
+ wrapper.current = element;
+ if (!slot.current) {
+ mountContent(element);
+ }
+ },
+ [mountContent],
+ );
+
+ return (
+
+ ["block"]
+ }
+ editor={props.extension.options.editor}
+ contentRef={contentRef}
+ />
+
+ );
+ }
+
return {
config: blockConfig,
implementation: {
- ...blockImplementation,
+ ...coreImplementation,
toExternalHTML(block, editor, context) {
- const BlockContent =
- blockImplementation.toExternalHTML || blockImplementation.render;
- const output = renderToDOMSpec((refCB) => {
- return (
-
- {
- refCB(element);
- if (element) {
- element.className = mergeCSSClasses(
- "bn-inline-content",
- element.className,
- );
- }
- }}
- context={context}
- />
-
- );
- }, editor);
- return output;
+ if (!blockImplementation.toExternalHTML) {
+ return undefined;
+ }
+ return renderStatic({
+ BlockContent: blockImplementation.toExternalHTML,
+ block,
+ editor,
+ domAttributes: this.blockContentDOMAttributes,
+ isFileBlock:
+ blockImplementation.meta?.fileBlockAccept !== undefined,
+ context,
+ });
},
render(block, editor) {
if (this.renderType === "nodeView") {
@@ -268,82 +408,128 @@ export function createReactBlockSpec<
// constructed (itself guarded, via `getBlockFromNodeView`). Seeds
// the fallback below so there is always something to render.
const initialBlock = block;
+ const BlockContent = blockImplementation.render;
+ const blockContentDOMAttributes = this.blockContentDOMAttributes;
- return ReactNodeViewRenderer(
- (props: NodeViewProps) => {
- // Vanilla JS node views are recreated on each update. However,
- // using `ReactNodeViewRenderer` makes it so the node view is
- // only created once, so the block we get in the node view will
- // be outdated. Therefore, we have to get the block in the
- // `ReactNodeViewRenderer` instead. That position can be stale,
- // so resolving it is guarded (see `useNodeViewBlock`).
- const block = useNodeViewBlock(props, initialBlock);
+ function BlockNodeView(props: NodeViewProps) {
+ const block = useNodeViewBlock(props, initialBlock);
+ const ref = useReactNodeView().nodeViewContentRef;
+ if (!ref) {
+ throw new Error("nodeViewContentRef is not set");
+ }
- const ref = useReactNodeView().nodeViewContentRef;
+ const mountContent = ref;
+ function contentRef(element: HTMLElement | null) {
+ mountContent(element);
+ if (!element) {
+ return;
+ }
+ element.dataset.nodeViewContent = "";
+ if (!isContainer) {
+ element.className = mergeCSSClasses(
+ "bn-inline-content",
+ element.className,
+ );
+ return;
+ }
- if (!ref) {
- throw new Error("nodeViewContentRef is not set");
+ // Refs also run when author state replaces the root or slot.
+ element.setAttribute("data-children-of", blockConfig.type);
+ const root = element.closest(
+ "[data-node-view-wrapper]",
+ )?.firstElementChild;
+ if (!(root instanceof HTMLElement)) {
+ throw new Error(
+ "Container content must be inside its node view wrapper.",
+ );
}
+ applyContainerAttributes(
+ root,
+ blockConfig.type,
+ block.props,
+ blockConfig.propSchema,
+ block.id,
+ );
+ root.toggleAttribute("data-selected", props.selected);
+ }
- const BlockContent = blockImplementation.render;
+ const content = (
+
+ );
+ if (isContainer) {
return (
-
- {
- ref(element);
- if (element) {
- element.className = mergeCSSClasses(
- "bn-inline-content",
- element.className,
- );
- element.dataset.nodeViewContent = "";
- }
- }}
- />
-
+
+ {content}
+
);
- },
- {
- className: "bn-react-node-view-renderer",
- },
- )(this.props!) as ReturnType;
- } else {
- const BlockContent = blockImplementation.render;
- const output = renderToDOMSpec((refCB) => {
+ }
return (
- {
- refCB(element);
- if (element) {
- element.className = mergeCSSClasses(
- "bn-inline-content",
- element.className,
- );
- }
- }}
- />
+ {content}
);
- }, editor);
- return output;
+ }
+
+ const nodeView = ReactNodeViewRenderer(BlockNodeView, {
+ // The container class is separate because it removes the
+ // box the regular class relies on (see `Block.css`).
+ className: isContainer
+ ? "bn-react-node-view-renderer bn-container-node-view"
+ : "bn-react-node-view-renderer",
+ })(this.props!) as ReturnType<
+ NonNullable
+ >;
+
+ // The container's author slot determines layout, not TipTap's host.
+ if (isContainer && nodeView.contentDOM) {
+ nodeView.contentDOM.style.display = "contents";
+ }
+
+ return nodeView;
+ } else {
+ return renderStatic({
+ BlockContent: blockImplementation.render,
+ block,
+ editor,
+ domAttributes: this.blockContentDOMAttributes,
+ });
}
},
+ ...(Frame
+ ? ({
+ // Serialization uses the same component through the existing
+ // static renderer. Live rendering uses the outer node view below.
+ renderFrame(block, editor) {
+ const { dom, contentDOM } = renderToDOMSpec(
+ (contentRef) => (
+
+ ),
+ editor,
+ );
+ return contentDOM ? { dom, slot: contentDOM } : undefined;
+ },
+ frameNodeView: ReactNodeViewRenderer(FrameNodeView, {
+ className: "bn-react-node-view-renderer bn-container-node-view",
+ }),
+ } satisfies Pick<
+ BlockImplementation,
+ "renderFrame" | "frameNodeView"
+ >)
+ : {}),
},
extensions: extensions,
};
diff --git a/packages/react/src/schema/useNodeViewBlock.ts b/packages/react/src/schema/useNodeViewBlock.ts
index 02393a2fd0..2b96af297f 100644
--- a/packages/react/src/schema/useNodeViewBlock.ts
+++ b/packages/react/src/schema/useNodeViewBlock.ts
@@ -1,4 +1,4 @@
-import { Block, getBlockFromPos } from "@blocknote/core";
+import { Block, getBlockFromPos, nodeToBlock } from "@blocknote/core";
import type { NodeViewProps } from "@tiptap/react";
import { useRef } from "react";
@@ -42,6 +42,11 @@ export function useNodeViewBlock(
const lastBlockRef = useRef(initialBlock);
const doc = props.view.state.doc;
+ // Container node views already receive the complete block node.
+ if (props.node.type.isInGroup("bnBlock")) {
+ return nodeToBlock(props.node, doc);
+ }
+
try {
// Deliberate render-phase write: a monotonic "last good value" cache, so a
// repeated render (e.g. StrictMode's double invoke) recomputes the same
diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts
index d4e59a60b4..1001b8cbe8 100644
--- a/packages/react/vite.config.ts
+++ b/packages/react/vite.config.ts
@@ -1,7 +1,7 @@
import react from "@vitejs/plugin-react";
import * as path from "path";
import { webpackStats } from "rollup-plugin-webpack-stats";
-import { defineConfig, type UserConfig } from "vite-plus";
+import { configDefaults, defineConfig, type UserConfig } from "vite-plus";
import pkg from "./package.json";
// import eslintPlugin from "vite-plugin-eslint";
@@ -26,6 +26,9 @@ export default defineConfig(
test: {
environment: "jsdom",
setupFiles: ["./vitestSetup.ts"],
+ // `.browser.test` files need a real browser; the tests package's
+ // browser suite runs them.
+ exclude: [...configDefaults.exclude, "**/*.browser.test.*"],
},
plugins: [react(), webpackStats()],
// used so that vitest resolves the core package from the sources instead of the built version
diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts
index 11367c90f2..c1ca3a244c 100644
--- a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts
+++ b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts
@@ -1,5 +1,6 @@
import {
BlockNoteSchema,
+ createBlockSpec,
defaultBlockSpecs,
createPageBreakBlockSpec,
PartialBlock,
@@ -416,6 +417,163 @@ describe("exporter", () => {
);
});
+describe("custom container blocks", () => {
+ const Box = createBlockSpec(
+ {
+ type: "box" as const,
+ propSchema: {},
+ content: "none",
+ children: { allow: "blocks" },
+ },
+ {
+ render: (block: any) => {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-node-type", "box");
+ dom.setAttribute("data-id", block.id);
+ return { dom, contentDOM: dom };
+ },
+ },
+ )();
+
+ const boxSchema = BlockNoteSchema.create({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ box: Box,
+ },
+ });
+
+ const boxDocument = partialBlocksToBlocksForTesting(boxSchema, [
+ {
+ type: "box",
+ children: [
+ { type: "paragraph", content: "First" },
+ { type: "paragraph", content: "Second" },
+ ],
+ },
+ ] as any);
+
+ // A titled block: inline content (the title) plus children (the body). The
+ // mapping renders the title into its own paragraph and places the children
+ // after it; because the block counts as a container, transformBlocks must
+ // not append them a second time.
+ const Alert = createBlockSpec(
+ {
+ type: "alert" as const,
+ propSchema: {},
+ content: "inline",
+ children: { allow: "blocks" },
+ },
+ {
+ render: (block: any) => {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-node-type", "alert");
+ dom.setAttribute("data-id", block.id);
+ return { dom, contentDOM: dom };
+ },
+ renderFrame: (_block: any) => {
+ const dom = document.createElement("div");
+ dom.className = "alert-box";
+ return { dom, slot: dom };
+ },
+ },
+ )();
+
+ const alertSchema = BlockNoteSchema.create({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ alert: Alert,
+ },
+ });
+
+ const alertDocument = partialBlocksToBlocksForTesting(alertSchema, [
+ {
+ type: "alert",
+ content: "Heads up",
+ children: [
+ { type: "paragraph", content: "First" },
+ { type: "paragraph", content: "Second" },
+ ],
+ },
+ ] as any);
+ it("passes children to a custom container mapping", async () => {
+ const exporter = new DOCXExporter(
+ boxSchema,
+ {
+ ...docxDefaultSchemaMappings,
+ blockMapping: {
+ ...docxDefaultSchemaMappings.blockMapping,
+ box: (
+ _block: any,
+ _exporter: any,
+ _nesting: any,
+ _index: any,
+ children: any,
+ ) =>
+ new Paragraph({
+ children: [new TextRun(`BOX(${children?.length ?? 0})`)],
+ }),
+ },
+ } as any,
+ { resolveFileUrl: testResolveFileUrl },
+ );
+
+ const transformed = await exporter.transformBlocks(boxDocument as any);
+ expect(transformed).toHaveLength(1);
+ const xml = JSON.stringify(transformed[0]);
+ expect(xml).toContain("BOX(2)");
+ });
+
+ it("throws a clear error for an unmapped container block", async () => {
+ const exporter = new DOCXExporter(
+ boxSchema,
+ docxDefaultSchemaMappings as any,
+ { resolveFileUrl: testResolveFileUrl },
+ );
+
+ await expect(exporter.transformBlocks(boxDocument as any)).rejects.toThrow(
+ /container block type "box"/,
+ );
+ });
+
+ it("renders a titled block's title and places its children inside", async () => {
+ const exporter = new DOCXExporter(
+ alertSchema,
+ {
+ ...docxDefaultSchemaMappings,
+ blockMapping: {
+ ...docxDefaultSchemaMappings.blockMapping,
+ alert: (
+ block: any,
+ exporter: any,
+ _nesting: any,
+ _index: any,
+ children: any,
+ ) => [
+ new Paragraph({
+ children: [
+ new TextRun("ALERT:"),
+ ...exporter.transformInlineContent(block.content),
+ ],
+ }),
+ ...(children ?? []),
+ ],
+ },
+ } as any,
+ { resolveFileUrl: testResolveFileUrl },
+ );
+
+ const transformed = await exporter.transformBlocks(alertDocument as any);
+ // Title paragraph plus the two children - handed to the mapping once,
+ // not appended again after it.
+ expect(transformed).toHaveLength(3);
+ const xml = JSON.stringify(transformed);
+ expect(xml).toContain("ALERT:");
+ expect(xml.indexOf("Heads up")).toBeGreaterThan(xml.indexOf("ALERT:"));
+ expect(xml.indexOf("First")).toBeGreaterThan(xml.indexOf("Heads up"));
+ expect(xml).toContain("Second");
+ });
+});
+
function prettify(sourceXml: string) {
let ret = xmlFormat(sourceXml);
diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.ts b/packages/xl-docx-exporter/src/docx/docxExporter.ts
index f27a4b6bcb..a78baec9cc 100644
--- a/packages/xl-docx-exporter/src/docx/docxExporter.ts
+++ b/packages/xl-docx-exporter/src/docx/docxExporter.ts
@@ -56,7 +56,7 @@ export class DOCXExporter<
/**
* The schema of your editor. The mappings are automatically typed checked against this schema.
*/
- protected readonly schema: BlockNoteSchema,
+ schema: BlockNoteSchema,
/**
* The mappings that map the BlockNote schema to the docxjs content.
* Pass {@link docxDefaultSchemaMappings} for the default schema.
@@ -158,7 +158,7 @@ export class DOCXExporter<
let children = await this.transformBlocks(b.children, nestingLevel + 1);
- if (!["columnList", "column"].includes(b.type)) {
+ if (!this.isContainerBlock(b)) {
children = children.map((c, _i) => {
// NOTE: nested tables not supported (we can't insert the new Tab before a table)
if (
@@ -178,17 +178,16 @@ export class DOCXExporter<
// The `numberedListIndex` slot carries the numbering instance for the docx
// block mappings (bullet/numbered list items); other block types ignore it.
const self = await this.mapBlock(
- b as any,
+ b,
nestingLevel,
numberingInstance,
children,
- ); // TODO: any
- if (["columnList", "column"].includes(b.type)) {
- ret.push(self as Table);
- } else if (Array.isArray(self)) {
- ret.push(...self, ...children);
- } else {
- ret.push(self, ...children);
+ );
+ ret.push(...(Array.isArray(self) ? self : [self]));
+ // A container's mapping is handed its children and places them itself,
+ // so they must not be appended after it as well.
+ if (!this.isContainerBlock(b)) {
+ ret.push(...children);
}
}
return ret;
diff --git a/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx b/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx
index a3ba8b653e..716dd41d8a 100644
--- a/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx
+++ b/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx
@@ -542,10 +542,19 @@ export const createReactEmailBlockMappingForDefaultSchema = (
);
},
// Email clients handle side-by-side layout poorly, so columns are stacked:
- // these wrappers render nothing themselves and the exporter's generic child
- // rendering stacks the column contents vertically.
- column: () => <>>,
- columnList: () => <>>,
+ // these container mappings place their children (which the exporter passes
+ // in) one after another, with no wrapper of their own - so the column
+ // contents render flat, in document order.
+ column: (_block, _exporter, _nestingLevel, _numberedListIndex, children) => (
+ <>{children}>
+ ),
+ columnList: (
+ _block,
+ _exporter,
+ _nestingLevel,
+ _numberedListIndex,
+ children,
+ ) => <>{children}>,
});
// Export the original mapping for backward compatibility
diff --git a/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx b/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx
index 91ef40cf36..6fe9ab7b06 100644
--- a/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx
+++ b/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx
@@ -3,15 +3,31 @@ import { ReactEmailExporter } from "./reactEmailExporter.jsx";
import { reactEmailDefaultSchemaMappings } from "./defaultSchema/index.js";
import {
BlockNoteSchema,
+ createBlockSpec,
createPageBreakBlockSpec,
defaultBlockSpecs,
} from "@blocknote/core";
+import { ColumnBlock, ColumnListBlock } from "@blocknote/xl-multi-column";
+import { partialBlocksToBlocksForTesting } from "@shared/formatConversionTestUtil.js";
import { testDocument } from "@shared/testDocument.js";
+// Schema including the multi-column blocks, matching the shared testDocument.
+// The columns are container blocks, so the exporter only recognizes them as
+// such (and lets their mappings place the children) when they're in the
+// schema it was constructed with.
+const fullSchema = BlockNoteSchema.create({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ pageBreak: createPageBreakBlockSpec(),
+ column: ColumnBlock,
+ columnList: ColumnListBlock,
+ },
+});
+
describe("react email exporter", () => {
it("should export a document (HTML snapshot)", async () => {
const exporter = new ReactEmailExporter(
- BlockNoteSchema.create(),
+ fullSchema,
reactEmailDefaultSchemaMappings,
);
@@ -21,12 +37,7 @@ describe("react email exporter", () => {
it("should export a document with preview", async () => {
const exporter = new ReactEmailExporter(
- BlockNoteSchema.create({
- blockSpecs: {
- ...defaultBlockSpecs,
- pageBreak: createPageBreakBlockSpec(),
- },
- }),
+ fullSchema,
reactEmailDefaultSchemaMappings,
);
@@ -38,12 +49,7 @@ describe("react email exporter", () => {
it("should export a document with multiple preview lines", async () => {
const exporter = new ReactEmailExporter(
- BlockNoteSchema.create({
- blockSpecs: {
- ...defaultBlockSpecs,
- pageBreak: createPageBreakBlockSpec(),
- },
- }),
+ fullSchema,
reactEmailDefaultSchemaMappings,
);
@@ -655,7 +661,7 @@ describe("react email exporter", () => {
it("should handle document with custom body styles", async () => {
const exporter = new ReactEmailExporter(
- BlockNoteSchema.create(),
+ fullSchema,
reactEmailDefaultSchemaMappings,
);
@@ -668,3 +674,93 @@ describe("react email exporter", () => {
);
});
});
+
+describe("titled blocks", () => {
+ // A titled block: inline content (the title) plus children (the body). The
+ // mapping renders the title and places the children inside its own box;
+ // because the block counts as a container, transformBlocks must not render
+ // them after it as an indented sibling list.
+ const Alert = createBlockSpec(
+ {
+ type: "alert" as const,
+ propSchema: {},
+ content: "inline",
+ children: { allow: "blocks" },
+ },
+ {
+ render: (block: any) => {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-node-type", "alert");
+ dom.setAttribute("data-id", block.id);
+ return { dom, contentDOM: dom };
+ },
+ renderFrame: (_block: any) => {
+ const dom = document.createElement("div");
+ dom.className = "alert-box";
+ return { dom, slot: dom };
+ },
+ },
+ )();
+
+ const alertSchema = BlockNoteSchema.create({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ alert: Alert,
+ },
+ });
+
+ const alertDocument = partialBlocksToBlocksForTesting(alertSchema, [
+ {
+ type: "alert",
+ content: "Heads up",
+ children: [
+ { type: "paragraph", content: "First" },
+ { type: "paragraph", content: "Second" },
+ ],
+ },
+ ] as any);
+
+ it("throws a clear error for an unmapped container block", async () => {
+ const exporter = new ReactEmailExporter(
+ alertSchema,
+ reactEmailDefaultSchemaMappings as any,
+ );
+
+ await expect(
+ exporter.transformBlocks(alertDocument as any),
+ ).rejects.toThrow(/container block type "alert"/);
+ });
+
+ it("renders a titled block's title and places its children inside", async () => {
+ const exporter = new ReactEmailExporter(alertSchema, {
+ ...reactEmailDefaultSchemaMappings,
+ blockMapping: {
+ ...reactEmailDefaultSchemaMappings.blockMapping,
+ alert: (
+ block: any,
+ exporter: any,
+ _nestingLevel: any,
+ _numberedListIndex: any,
+ children: any,
+ ) => (
+
+ {exporter.transformInlineContent(block.content)}
+ {children}
+
+ ),
+ },
+ } as any);
+
+ const html = await exporter.toReactEmailDocument(alertDocument as any);
+
+ // Title and children all sit inside the mapping's own box - placed by
+ // the mapping, not rendered after it in an indented sibling list.
+ const boxIdx = html.indexOf("data-alert-box");
+ expect(boxIdx).toBeGreaterThan(-1);
+ const titleIdx = html.indexOf("Heads up");
+ expect(titleIdx).toBeGreaterThan(boxIdx);
+ expect(html.indexOf("First")).toBeGreaterThan(titleIdx);
+ expect(html).toContain("Second");
+ expect(html).not.toContain("margin-left:24px");
+ });
+});
diff --git a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx
index c0d19108ae..3a2c942730 100644
--- a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx
+++ b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx
@@ -87,11 +87,7 @@ export class ReactEmailExporter<
j++, itemIndex++
) {
const block = blocks[j];
- const liContent = (await this.mapBlock(
- block as any,
- nestingLevel,
- itemIndex,
- )) as any;
+ const liContent = await this.mapBlock(block, nestingLevel, itemIndex);
let nestedList: React.ReactElement[] = [];
if (block.children && block.children.length > 0) {
nestedList = await this.renderNestedLists(
@@ -152,11 +148,11 @@ export class ReactEmailExporter<
j++, itemIndex++
) {
const listItem = children[j];
- const liContent = (await this.mapBlock(
- listItem as any,
+ const liContent = await this.mapBlock(
+ listItem,
nestingLevel,
itemIndex,
- )) as any;
+ );
const style = this.blocknoteDefaultPropsToReactEmailStyle(
listItem.props as any,
);
@@ -246,26 +242,17 @@ export class ReactEmailExporter<
i = nextIndex;
continue;
}
- // Multi-column blocks stack their content vertically in email (their
- // mappings render nothing themselves). The columns' children are
- // structural, not nested sub-content, so they render flat - no
- // indentation wrapper, and at the *same* nesting level (a level bump
- // per wrapper would report column content as deeply nested to
- // level-sensitive mappings).
- if (b.type === "columnList" || b.type === "column") {
- ret.push(
-
- {await this.transformBlocks(b.children, nestingLevel)}
- ,
- );
- i++;
- continue;
- }
-
- // Non-list blocks
+ const isContainer = this.isContainerBlock(b);
const children = await this.transformBlocks(b.children, nestingLevel + 1);
- const self = (await this.mapBlock(b as any, nestingLevel, 0)) as any;
- const style = this.blocknoteDefaultPropsToReactEmailStyle(b.props as any);
+ const self = await this.mapBlock(
+ b,
+ nestingLevel,
+ 0,
+ isContainer ? children : undefined,
+ );
+ const style = isContainer
+ ? {}
+ : this.blocknoteDefaultPropsToReactEmailStyle(b.props);
ret.push(
@@ -274,7 +261,7 @@ export class ReactEmailExporter<
) : (
self
)}
- {children.length > 0 && (
+ {!isContainer && children.length > 0 && (
{children}
)}
,
diff --git a/packages/xl-multi-column/src/blocks/Columns/index.ts b/packages/xl-multi-column/src/blocks/Columns/index.ts
index 2e49261ec6..8be04b6d1f 100644
--- a/packages/xl-multi-column/src/blocks/Columns/index.ts
+++ b/packages/xl-multi-column/src/blocks/Columns/index.ts
@@ -1,28 +1,68 @@
+import { createBlockSpec } from "@blocknote/core";
+
+import { ColumnResizeExtension } from "../../extensions/ColumnResize/ColumnResizeExtension.js";
import { MultiColumnDropHandlerExtension } from "../../extensions/DropCursor/multiColumnHandleDropPlugin.js";
-import { Column } from "../../pm-nodes/Column.js";
-import { ColumnList } from "../../pm-nodes/ColumnList.js";
-import { createBlockSpecFromTiptapNode } from "@blocknote/core";
+const COLUMN_WIDTH_DEFAULT = 1;
-export const ColumnBlock = createBlockSpecFromTiptapNode(
+export const ColumnBlock = createBlockSpec(
{
- node: Column,
- type: "column",
+ type: "column" as const,
+ propSchema: {
+ width: {
+ default: COLUMN_WIDTH_DEFAULT,
+ },
+ },
content: "none",
+ children: { allow: "blocks" },
+ placeable: "namedOnly",
},
{
- width: {
- default: 1,
+ meta: {
+ draggable: false,
+ },
+ render: (block) => {
+ const dom = document.createElement("div");
+ dom.className = "bn-block-column";
+ dom.style.flexGrow = String(block.props.width ?? COLUMN_WIDTH_DEFAULT);
+
+ return {
+ dom,
+ contentDOM: dom,
+ update: (newBlock) => {
+ dom.style.flexGrow = String(
+ newBlock.attrs.width ?? COLUMN_WIDTH_DEFAULT,
+ );
+ },
+ };
},
},
- [MultiColumnDropHandlerExtension()],
-);
+ [MultiColumnDropHandlerExtension(), ColumnResizeExtension()],
+)();
-export const ColumnListBlock = createBlockSpecFromTiptapNode(
+export const ColumnListBlock = createBlockSpec(
{
- node: ColumnList,
- type: "columnList",
+ type: "columnList" as const,
+ propSchema: {},
content: "none",
+ children: {
+ allow: ["column"],
+ min: 2,
+ },
+ },
+ {
+ meta: {
+ draggable: false,
+ },
+ render: () => {
+ const dom = document.createElement("div");
+ dom.className = "bn-block-column-list";
+ dom.style.display = "flex";
+
+ return {
+ dom,
+ contentDOM: dom,
+ };
+ },
},
- {},
-);
+)();
diff --git a/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts
index 5713466a6d..44bcc255c7 100644
--- a/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts
+++ b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts
@@ -1,6 +1,5 @@
-import { BlockNoteEditor, getNodeById } from "@blocknote/core";
+import { BlockNoteEditor, createExtension, getNodeById } from "@blocknote/core";
import { SideMenuExtension } from "@blocknote/core/extensions";
-import { Extension } from "@tiptap/core";
import { Node } from "prosemirror-model";
import { Plugin, PluginKey, PluginView } from "prosemirror-state";
import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
@@ -41,13 +40,75 @@ type ColumnResizeState = {
columnList: ColumnData;
};
-type ColumnState =
+// Exported for tests only - not part of the package's public API.
+export type ColumnState =
| ColumnDefaultState
| ColumnHoverState
| ColumnHoverColumnListState
| ColumnResizeState;
-const columnResizePluginKey = new PluginKey("ColumnResizePlugin");
+// Exported for tests only - not part of the package's public API.
+export const columnResizePluginKey = new PluginKey(
+ "ColumnResizePlugin",
+);
+
+function isAdjacentColumnPair(
+ doc: Node,
+ columnList: Pick,
+ leftColumn: Pick,
+ rightColumn: Pick,
+): boolean {
+ const left = doc.resolve(leftColumn.posBeforeNode);
+ const right = doc.resolve(rightColumn.posBeforeNode);
+ return (
+ columnList.node.type.name === "columnList" &&
+ leftColumn.node.type.name === "column" &&
+ rightColumn.node.type.name === "column" &&
+ left.parent === columnList.node &&
+ right.parent === columnList.node &&
+ left.index() + 1 === right.index()
+ );
+}
+
+// Resolve stored positions after edits; removed or separated columns end the interaction.
+function refreshColumnState(state: ColumnState, doc: Node): ColumnState {
+ if (state.type === "default") {
+ return state;
+ }
+
+ const columnList = getNodeById(state.columnList.id, doc);
+ if (!columnList) {
+ return { type: "default" };
+ }
+ const refreshedList = { ...state.columnList, ...columnList };
+ if (state.type === "hover-column-list") {
+ return { ...state, columnList: refreshedList };
+ }
+
+ const left = getNodeById(state.leftColumn.id, doc);
+ const right = getNodeById(state.rightColumn.id, doc);
+ if (!left || !right || !isAdjacentColumnPair(doc, columnList, left, right)) {
+ return { type: "default" };
+ }
+
+ // Narrow before spreading so resize columns retain their starting widths.
+ switch (state.type) {
+ case "hover-column":
+ return {
+ ...state,
+ columnList: refreshedList,
+ leftColumn: { ...state.leftColumn, ...left },
+ rightColumn: { ...state.rightColumn, ...right },
+ };
+ case "resize":
+ return {
+ ...state,
+ columnList: refreshedList,
+ leftColumn: { ...state.leftColumn, ...left },
+ rightColumn: { ...state.rightColumn, ...right },
+ };
+ }
+}
class ColumnResizePluginView implements PluginView {
editor: BlockNoteEditor;
@@ -428,22 +489,26 @@ const createColumnResizePlugin = (editor: BlockNoteEditor) =>
state: {
init: () => ({ type: "default" }) as ColumnState,
apply: (tr, oldPluginState) => {
- const newPluginState = tr.getMeta(columnResizePluginKey) as
+ const metaPluginState = tr.getMeta(columnResizePluginKey) as
| ColumnState
| undefined;
- return newPluginState === undefined ? oldPluginState : newPluginState;
+ const pluginState =
+ metaPluginState === undefined ? oldPluginState : metaPluginState;
+
+ // The stored column nodes & positions were resolved against an older
+ // doc, so when the doc changes they must be re-resolved against the
+ // new one - a backspace may have removed a hovered column or
+ // unwrapped the column list entirely.
+ return tr.docChanged
+ ? refreshColumnState(pluginState, tr.doc)
+ : pluginState;
},
},
view: (view) => new ColumnResizePluginView(editor, view),
});
-export const createColumnResizeExtension = (
- editor: BlockNoteEditor,
-) =>
- Extension.create({
- name: "columnResize",
- addProseMirrorPlugins() {
- return [createColumnResizePlugin(editor)];
- },
- });
+export const ColumnResizeExtension = createExtension(({ editor }) => ({
+ key: "columnResize",
+ prosemirrorPlugins: [createColumnResizePlugin(editor)],
+}));
diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts
index 1c64a25d1b..4e135aa143 100644
--- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts
+++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts
@@ -51,12 +51,15 @@ export function createMultiColumnHandleDropPlugin(
// emptied target in the same position, so do nothing. This also
// keeps the column's ID and width instead of resetting them.
let allTargetChildrenDragged = true;
- blockInfo.block.node.forEach((child: any) => {
+ blockInfo.block.node.forEach((child) => {
if (!draggedBlockIds.has(child.attrs.id)) {
allTargetChildrenDragged = false;
}
});
- if (allTargetChildrenDragged) {
+ if (
+ allTargetChildrenDragged &&
+ draggedBlockIds.size === blockInfo.block.node.childCount
+ ) {
return true;
}
@@ -116,36 +119,30 @@ export function createMultiColumnHandleDropPlugin(
blocksAlreadyInColumnList.add(block.id);
return false;
}),
- }))
- // Remove empty columns (can happen when dragged blocks are
- // removed).
- .filter((column) => column.children.length > 0);
-
- // The insertion index is computed on the remaining columns, as
- // removing an emptied column before the drop target shifts the
- // target's position in the list.
- const targetIndex = remainingColumns.findIndex(
+ }));
+
+ // Count surviving columns before the original drop boundary. This
+ // also works when the selection empties the target column itself.
+ const originalTargetIndex = columnList.children.findIndex(
(column) => column.id === targetColumnId,
);
- if (targetIndex === -1) {
- // The target column can only be missing if the drag emptied it,
- // which is handled as a no-op above.
- throw new Error(
- "Drop target column not found in the remaining columns",
- );
- }
- const insertionIndex =
- edgePos.position === "left" ? targetIndex : targetIndex + 1;
+ const boundary =
+ originalTargetIndex + (edgePos.position === "right" ? 1 : 0);
+ const insertionIndex = remainingColumns
+ .slice(0, boundary)
+ .filter((column) => column.children.length > 0).length;
// Insert the dragged blocks as a new column in the correct
// position.
- const newChildren = remainingColumns.toSpliced(insertionIndex, 0, {
- type: "column",
- children: draggedBlocks,
- props: {},
- content: undefined,
- id: UniqueID.options.generateID(),
- });
+ const newChildren = remainingColumns
+ .filter((column) => column.children.length > 0)
+ .toSpliced(insertionIndex, 0, {
+ type: "column",
+ children: draggedBlocks,
+ props: {},
+ content: undefined,
+ id: UniqueID.options.generateID(),
+ });
const blocksToRemove = draggedBlocks.filter(
(block) =>
@@ -156,9 +153,13 @@ export function createMultiColumnHandleDropPlugin(
editor.removeBlocks(blocksToRemove);
}
- editor.updateBlock(columnList, {
- children: newChildren,
- });
+ if (newChildren.length === 1) {
+ editor.replaceBlocks([columnList], draggedBlocks);
+ } else {
+ editor.updateBlock(columnList, {
+ children: newChildren,
+ });
+ }
} else {
// Create new columnList with blocks as columns
const block = nodeToBlock(blockInfo.block.node, view.state.doc);
diff --git a/packages/xl-multi-column/src/pm-nodes/Column.ts b/packages/xl-multi-column/src/pm-nodes/Column.ts
deleted file mode 100644
index dccf60c74b..0000000000
--- a/packages/xl-multi-column/src/pm-nodes/Column.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { suggestionMarks } from "@blocknote/core";
-import { Node } from "@tiptap/core";
-
-import { createColumnResizeExtension } from "../extensions/ColumnResize/ColumnResizeExtension.js";
-
-export const Column = Node.create({
- name: "column",
- group: "bnBlock childContainer",
- // A block always contains content, and optionally a blockGroup which contains nested blocks
- content: "blockContainer+",
- priority: 40,
- defining: true,
- marks() {
- return suggestionMarks(this.editor);
- },
- addAttributes() {
- return {
- width: {
- // Why does each column have a default width of 1, i.e. 100%? Because
- // when creating a new column, we want to make sure that existing
- // column widths are preserved, while the new one also has a sensible
- // width. If we'd set it so all column widths must add up to 100%
- // instead, then each time a new column is created, we'd have to assign
- // it a width depending on the total number of columns and also adjust
- // the widths of the other columns. The same can be said for using px
- // instead of percent widths and making them add to the editor width. So
- // using this method is both simpler and computationally cheaper. This
- // is possible because we can set the `flex-grow` property to the width
- // value, which handles all the resizing for us, instead of manually
- // having to set the `width` property of each column.
- default: 1,
- parseHTML: (element) => {
- const attr = element.getAttribute("data-width");
- if (attr === null) {
- return null;
- }
-
- const parsed = parseFloat(attr);
- if (isFinite(parsed)) {
- return parsed;
- }
-
- return null;
- },
- renderHTML: (attributes) => {
- return {
- "data-width": (attributes.width as number).toString(),
- style: `flex-grow: ${attributes.width as number};`,
- };
- },
- },
- };
- },
-
- parseHTML() {
- return [
- {
- tag: "div",
- getAttrs: (element) => {
- if (typeof element === "string") {
- return false;
- }
-
- if (element.getAttribute("data-node-type") === this.name) {
- return {};
- }
-
- return false;
- },
- },
- ];
- },
-
- renderHTML({ HTMLAttributes }) {
- const column = document.createElement("div");
- column.className = "bn-block-column";
- column.setAttribute("data-node-type", this.name);
- for (const [attribute, value] of Object.entries(HTMLAttributes)) {
- column.setAttribute(attribute, value as any); // TODO as any
- }
-
- return {
- dom: column,
- contentDOM: column,
- };
- },
-
- addExtensions() {
- return [createColumnResizeExtension(this.options.editor)];
- },
-});
diff --git a/packages/xl-multi-column/src/pm-nodes/ColumnList.ts b/packages/xl-multi-column/src/pm-nodes/ColumnList.ts
deleted file mode 100644
index eeb06f4d4e..0000000000
--- a/packages/xl-multi-column/src/pm-nodes/ColumnList.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { suggestionMarks } from "@blocknote/core";
-import { Node } from "@tiptap/core";
-
-export const ColumnList = Node.create({
- name: "columnList",
- group: "childContainer bnBlock blockGroupChild",
- // A block always contains content, and optionally a blockGroup which contains nested blocks
- content: "column column+", // min two columns
- priority: 40, // should be below blockContainer
- defining: true,
- marks() {
- return suggestionMarks(this.editor);
- },
- parseHTML() {
- return [
- {
- tag: "div",
- getAttrs: (element) => {
- if (typeof element === "string") {
- return false;
- }
-
- if (element.getAttribute("data-node-type") === this.name) {
- return {};
- }
-
- return false;
- },
- },
- ];
- },
-
- renderHTML({ HTMLAttributes }) {
- const columnList = document.createElement("div");
- columnList.className = "bn-block-column-list";
- columnList.setAttribute("data-node-type", this.name);
- for (const [attribute, value] of Object.entries(HTMLAttributes)) {
- columnList.setAttribute(attribute, value as any); // TODO as any
- }
- columnList.style.display = "flex";
-
- return {
- dom: columnList,
- contentDOM: columnList,
- };
- },
-});
diff --git a/packages/xl-multi-column/src/test/commands/__snapshots__/insertBlocks.test.ts.snap b/packages/xl-multi-column/src/test/commands/__snapshots__/insertBlocks.test.ts.snap
index 476357f363..c6019e4dd8 100644
--- a/packages/xl-multi-column/src/test/commands/__snapshots__/insertBlocks.test.ts.snap
+++ b/packages/xl-multi-column/src/test/commands/__snapshots__/insertBlocks.test.ts.snap
@@ -429,6 +429,226 @@ exports[`Test insertBlocks > Insert column with paragraph into column list 1`] =
]
`;
+exports[`Test insertBlocks > Insert empty column list 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "2",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [],
+ "id": "3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "4",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ ],
+ "content": undefined,
+ "id": "0",
+ "props": {},
+ "type": "columnList",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "column-0",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 3",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "column-1",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ ],
+ "content": undefined,
+ "id": "column-list-0",
+ "props": {},
+ "type": "columnList",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
exports[`Test insertBlocks > Insert paragraph into column 1`] = `
[
{
diff --git a/packages/xl-multi-column/src/test/commands/__snapshots__/moveBlocks.test.ts.snap b/packages/xl-multi-column/src/test/commands/__snapshots__/moveBlocks.test.ts.snap
index d9021ec0e0..7851a21e5d 100644
--- a/packages/xl-multi-column/src/test/commands/__snapshots__/moveBlocks.test.ts.snap
+++ b/packages/xl-multi-column/src/test/commands/__snapshots__/moveBlocks.test.ts.snap
@@ -1,5 +1,591 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+exports[`Empty a column by moving out of it > Move the only block out of the first column 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Before",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-before",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Only 0",
+ "type": "text",
+ },
+ ],
+ "id": "only-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [],
+ "id": "0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "column-single-0",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Only 1",
+ "type": "text",
+ },
+ ],
+ "id": "only-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "column-single-1",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ ],
+ "content": undefined,
+ "id": "column-list-single",
+ "props": {},
+ "type": "columnList",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "After",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-after",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Empty a column by moving out of it > Move the only block out of the last column 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Before",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-before",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Only 0",
+ "type": "text",
+ },
+ ],
+ "id": "only-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "column-single-0",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [],
+ "id": "0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "column-single-1",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ ],
+ "content": undefined,
+ "id": "column-list-single",
+ "props": {},
+ "type": "columnList",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Only 1",
+ "type": "text",
+ },
+ ],
+ "id": "only-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "After",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-after",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Move a column > Move column down 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "0",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 3",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "column-1",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ ],
+ "content": undefined,
+ "id": "column-list-0",
+ "props": {},
+ "type": "columnList",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Move a column > Move column up 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 3",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "column-0",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "0",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ ],
+ "content": undefined,
+ "id": "column-list-0",
+ "props": {},
+ "type": "columnList",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
exports[`Move past empty sibling within a column > Move down below empty sibling 1`] = `
[
{
diff --git a/packages/xl-multi-column/src/test/commands/enter.test.ts b/packages/xl-multi-column/src/test/commands/enter.test.ts
new file mode 100644
index 0000000000..273b68508c
--- /dev/null
+++ b/packages/xl-multi-column/src/test/commands/enter.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { BlockNoteEditor } from "@blocknote/core";
+
+import { setupTestEnv } from "../setupTestEnv.js";
+
+const getEditor = setupTestEnv();
+
+function pressEnter(editor: BlockNoteEditor) {
+ const view = editor._tiptapEditor.view;
+ const event = new KeyboardEvent("keydown", {
+ key: "Enter",
+ code: "Enter",
+ keyCode: 13,
+ bubbles: true,
+ });
+ view.someProp("handleKeyDown", (f: any) => f(view, event));
+}
+
+// Columns have no special Enter config: like any container, an
+// empty last block escapes on Enter. The generic mechanics (escape, ascent
+// past levels that can't hold the block, mid-container stays) are covered in
+// core's `containers.browser.test.ts`; these two tests use the real column
+// schema and its interaction with the column-list repair.
+describe("Enter exit from columns", () => {
+ it("typing then double-Enter escapes in two presses", () => {
+ const editor = getEditor();
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "columnList",
+ id: "cl-0",
+ children: [
+ {
+ type: "column",
+ id: "col-1",
+ children: [{ id: "col1-para", type: "paragraph", content: "col1" }],
+ },
+ {
+ type: "column",
+ id: "col-2",
+ children: [{ id: "col2-para", type: "paragraph", content: "col2" }],
+ },
+ ],
+ },
+ ]);
+
+ editor.setTextCursorPosition("col2-para", "end");
+ pressEnter(editor);
+
+ // First press: a new empty block inside the column.
+ expect(editor.document.map((block) => block.id)).toEqual(["cl-0"]);
+ const children = editor.getBlock("col-2")!.children;
+ expect(children).toHaveLength(2);
+ const created = children[1].id;
+ expect(editor.getTextCursorPosition().block.id).toBe(created);
+
+ pressEnter(editor);
+
+ // Second press: that block moves below the column list (a block can't sit
+ // between columns, so the escape lands below the whole list), caret along.
+ expect(editor.getBlock("col-2")!.children.map((child) => child.id)).toEqual(
+ ["col2-para"],
+ );
+ expect(editor.document.map((block) => block.id)).toEqual(["cl-0", created]);
+ expect(editor.getTextCursorPosition().block.id).toBe(created);
+ });
+
+ it("escaping a column's only block dissolves it and unwraps the list", () => {
+ // The exit empties the column, so the column list's `whenEmptied: "unwrap"`
+ // repair kicks in: the emptied column disappears, and the one-column
+ // list unwraps to the surviving column's blocks.
+ const editor = getEditor();
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "columnList",
+ id: "cl-0",
+ children: [
+ {
+ type: "column",
+ id: "col-1",
+ children: [{ id: "col1-para", type: "paragraph", content: "col1" }],
+ },
+ {
+ type: "column",
+ id: "col-2",
+ children: [{ id: "col2-empty", type: "paragraph", content: "" }],
+ },
+ ],
+ },
+ ]);
+
+ editor.setTextCursorPosition("col2-empty", "end");
+ pressEnter(editor);
+
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "col1-para",
+ "col2-empty",
+ ]);
+ });
+});
diff --git a/packages/xl-multi-column/src/test/commands/insertBlocks.test.ts b/packages/xl-multi-column/src/test/commands/insertBlocks.test.ts
index 319ceda379..329ba7ad12 100644
--- a/packages/xl-multi-column/src/test/commands/insertBlocks.test.ts
+++ b/packages/xl-multi-column/src/test/commands/insertBlocks.test.ts
@@ -6,60 +6,69 @@ const getEditor = setupTestEnv();
describe("Test insertBlocks", () => {
it("Insert empty column list", () => {
- // should throw an error as we don't allow empty column lists
- expect(() => {
- getEditor().insertBlocks(
- [{ type: "columnList" }],
- "paragraph-0",
- "after",
- );
- }).toThrow();
+ // An empty column list is filled to a valid two-column list (each with an
+ // empty paragraph) instead of throwing.
+ getEditor().insertBlocks([{ type: "columnList" }], "paragraph-0", "after");
+
+ expect(getEditor().document).toMatchSnapshot();
});
it("Insert column list with empty column", () => {
- // should throw an error as we don't allow empty columns
- expect(() => {
- getEditor().insertBlocks(
- [
- {
- type: "columnList",
- children: [
- {
- type: "column",
- },
- ],
- },
- ],
- "paragraph-0",
- "after",
- );
- }).toThrow();
+ // The empty column is padded with a paragraph, and the list is padded to
+ // its `min: 2` with a second column, instead of throwing.
+ getEditor().insertBlocks(
+ [
+ {
+ type: "columnList",
+ children: [
+ {
+ type: "column",
+ },
+ ],
+ },
+ ],
+ "paragraph-0",
+ "after",
+ );
+
+ const list = getEditor().document[1] as any;
+ expect(list.type).toBe("columnList");
+ expect(list.children).toHaveLength(2);
+ expect(list.children[0].children).toHaveLength(1);
+ expect(list.children[1].children).toHaveLength(1);
});
it("Insert column list with single column", () => {
- // should throw an error as we don't allow column list with single column
- expect(() => {
- getEditor().insertBlocks(
- [
- {
- type: "columnList",
- children: [
- {
- type: "column",
- children: [
- {
- type: "paragraph",
- content: "Inserted Column Paragraph",
- },
- ],
- },
- ],
- },
- ],
- "paragraph-0",
- "after",
- );
- }).toThrow();
+ // A one-column list is padded up to `min: 2` with a second column,
+ // instead of throwing.
+ getEditor().insertBlocks(
+ [
+ {
+ type: "columnList",
+ children: [
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ "paragraph-0",
+ "after",
+ );
+
+ const list = getEditor().document[1] as any;
+ expect(list.type).toBe("columnList");
+ expect(list.children).toHaveLength(2);
+ expect(list.children[0].children[0].content[0].text).toBe(
+ "Inserted Column Paragraph",
+ );
+ expect(list.children[1].children[0].content).toEqual([]);
});
it("Insert valid column list with two columns", () => {
diff --git a/packages/xl-multi-column/src/test/commands/moveBlocks.test.ts b/packages/xl-multi-column/src/test/commands/moveBlocks.test.ts
index 6970c6c036..474428330b 100644
--- a/packages/xl-multi-column/src/test/commands/moveBlocks.test.ts
+++ b/packages/xl-multi-column/src/test/commands/moveBlocks.test.ts
@@ -196,3 +196,64 @@ describe("Move past empty sibling within a column", () => {
expect(getEditor().document).toMatchSnapshot();
});
});
+
+// A `column` is `placeable: "namedOnly"`, so it can't be moved anywhere a
+// regular block goes: moving one dissolves it and moves its children instead.
+// The column list is left at its `min` of 2 by an empty replacement column,
+// rather than unwrapping - see the note on emptied columns below.
+describe("Move a column", () => {
+ it("Move column up", () => {
+ getEditor().moveBlocksUp("column-1");
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ it("Move column down", () => {
+ getEditor().moveBlocksDown("column-0");
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+});
+
+// A move is a rearrangement rather than a deletion, so a column it empties out
+// is deliberately left standing instead of being collapsed (see `moveBlocks`).
+describe("Empty a column by moving out of it", () => {
+ beforeEach(() => {
+ getEditor().replaceBlocks(getEditor().document, [
+ { id: "paragraph-before", type: "paragraph", content: "Before" },
+ {
+ id: "column-list-single",
+ type: "columnList",
+ children: [
+ {
+ id: "column-single-0",
+ type: "column",
+ children: [{ id: "only-0", type: "paragraph", content: "Only 0" }],
+ },
+ {
+ id: "column-single-1",
+ type: "column",
+ children: [{ id: "only-1", type: "paragraph", content: "Only 1" }],
+ },
+ ],
+ },
+ { id: "paragraph-after", type: "paragraph", content: "After" },
+ ]);
+ });
+
+ it("Move the only block out of the first column", () => {
+ getEditor().setTextCursorPosition("only-0");
+
+ getEditor().moveBlocksUp();
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ it("Move the only block out of the last column", () => {
+ getEditor().setTextCursorPosition("only-1");
+
+ getEditor().moveBlocksDown();
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+});
diff --git a/packages/xl-multi-column/src/test/commands/nestBlock.test.ts b/packages/xl-multi-column/src/test/commands/nestBlock.test.ts
new file mode 100644
index 0000000000..a19ea0034b
--- /dev/null
+++ b/packages/xl-multi-column/src/test/commands/nestBlock.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { setupTestEnv } from "../setupTestEnv.js";
+
+const getEditor = setupTestEnv();
+
+// Tab and Shift-Tab with a selection spanning two columns. There is no
+// nestable range inside the column list — it holds only `column`s — so the
+// range has to resolve outside it and move the list as a unit. Nesting used to
+// resolve the range at the columns themselves, where the preconditions can
+// never hold, making both keys silent no-ops.
+describe("Nest & unnest a selection spanning two columns", () => {
+ it("Nests the whole column list under its previous sibling", () => {
+ const editor = getEditor();
+
+ editor.setSelection("column-paragraph-1", "column-paragraph-2");
+
+ expect(editor.canNestBlock()).toBe(true);
+ editor.nestBlock();
+
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "paragraph-0",
+ "paragraph-1",
+ "paragraph-2",
+ ]);
+ expect(editor.getBlock("paragraph-1")!.children.map((c) => c.id)).toEqual([
+ "column-list-0",
+ ]);
+ // The list itself is untouched — only its position changed.
+ expect(editor.getBlock("column-list-0")!.children.map((c) => c.id)).toEqual(
+ ["column-0", "column-1"],
+ );
+ });
+
+ it("Unnests the whole column list out of its parent", () => {
+ const editor = getEditor();
+
+ editor.setSelection("column-paragraph-1", "column-paragraph-2");
+ editor.nestBlock();
+
+ editor.setSelection("column-paragraph-1", "column-paragraph-2");
+ expect(editor.canUnnestBlock()).toBe(true);
+ editor.unnestBlock();
+
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "paragraph-0",
+ "paragraph-1",
+ "column-list-0",
+ "paragraph-2",
+ ]);
+ expect(editor.getBlock("paragraph-1")!.children).toEqual([]);
+ expect(editor.getBlock("column-list-0")!.children.map((c) => c.id)).toEqual(
+ ["column-0", "column-1"],
+ );
+ });
+
+ it("Reports no nesting when the column list has no previous sibling", () => {
+ const editor = getEditor();
+ editor.replaceBlocks(editor.document, [
+ editor.getBlock("column-list-0")!,
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+
+ editor.setSelection("column-paragraph-1", "column-paragraph-2");
+
+ const before = editor.document;
+ expect(editor.canNestBlock()).toBe(false);
+ editor.nestBlock();
+ expect(editor.document).toEqual(before);
+ });
+});
diff --git a/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap
deleted file mode 100644
index 87b5f2e588..0000000000
--- a/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap
+++ /dev/null
@@ -1,408 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`Test fixColumnList > First of two columns empty 1`] = `
-{
- "content": [
- {
- "content": [
- {
- "attrs": {
- "id": null,
- },
- "content": [
- {
- "attrs": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "content": [
- {
- "text": "Paragraph 1",
- "type": "text",
- },
- ],
- "type": "paragraph",
- },
- ],
- "type": "blockContainer",
- },
- ],
- "type": "blockGroup",
- },
- ],
- "type": "doc",
-}
-`;
-
-exports[`Test fixColumnList > Last of two columns empty 1`] = `
-{
- "content": [
- {
- "content": [
- {
- "attrs": {
- "id": null,
- },
- "content": [
- {
- "attrs": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "content": [
- {
- "text": "Paragraph 1",
- "type": "text",
- },
- ],
- "type": "paragraph",
- },
- ],
- "type": "blockContainer",
- },
- ],
- "type": "blockGroup",
- },
- ],
- "type": "doc",
-}
-`;
-
-exports[`Test fixColumnList > Two empty columns 1`] = `
-{
- "content": [
- {
- "content": [
- {
- "attrs": {
- "id": null,
- },
- "content": [
- {
- "attrs": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
- "type": "blockContainer",
- },
- ],
- "type": "blockGroup",
- },
- ],
- "type": "doc",
-}
-`;
-
-exports[`Test removeEmptyColumns > First of two columns empty 1`] = `
-{
- "content": [
- {
- "content": [
- {
- "attrs": {
- "id": null,
- },
- "content": [
- {
- "attrs": {
- "id": null,
- "width": 1,
- },
- "content": [
- {
- "attrs": {
- "id": null,
- },
- "content": [
- {
- "attrs": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
- "type": "blockContainer",
- },
- ],
- "type": "column",
- },
- {
- "attrs": {
- "id": null,
- "width": 1,
- },
- "content": [
- {
- "attrs": {
- "id": null,
- },
- "content": [
- {
- "attrs": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "content": [
- {
- "text": "Paragraph 1",
- "type": "text",
- },
- ],
- "type": "paragraph",
- },
- ],
- "type": "blockContainer",
- },
- ],
- "type": "column",
- },
- ],
- "type": "columnList",
- },
- ],
- "type": "blockGroup",
- },
- ],
- "type": "doc",
-}
-`;
-
-exports[`Test removeEmptyColumns > Last of two columns empty 1`] = `
-{
- "content": [
- {
- "content": [
- {
- "attrs": {
- "id": null,
- },
- "content": [
- {
- "attrs": {
- "id": null,
- "width": 1,
- },
- "content": [
- {
- "attrs": {
- "id": null,
- },
- "content": [
- {
- "attrs": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "content": [
- {
- "text": "Paragraph 1",
- "type": "text",
- },
- ],
- "type": "paragraph",
- },
- ],
- "type": "blockContainer",
- },
- ],
- "type": "column",
- },
- {
- "attrs": {
- "id": null,
- "width": 1,
- },
- "content": [
- {
- "attrs": {
- "id": null,
- },
- "content": [
- {
- "attrs": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
- "type": "blockContainer",
- },
- ],
- "type": "column",
- },
- ],
- "type": "columnList",
- },
- ],
- "type": "blockGroup",
- },
- ],
- "type": "doc",
-}
-`;
-
-exports[`Test removeEmptyColumns > Start and end columns empty 1`] = `
-{
- "content": [
- {
- "content": [
- {
- "attrs": {
- "id": null,
- },
- "content": [
- {
- "attrs": {
- "id": null,
- "width": 1,
- },
- "content": [
- {
- "attrs": {
- "id": null,
- },
- "content": [
- {
- "attrs": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "content": [
- {
- "text": "Paragraph 1",
- "type": "text",
- },
- ],
- "type": "paragraph",
- },
- ],
- "type": "blockContainer",
- },
- ],
- "type": "column",
- },
- {
- "attrs": {
- "id": null,
- "width": 1,
- },
- "content": [
- {
- "attrs": {
- "id": null,
- },
- "content": [
- {
- "attrs": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "content": [
- {
- "text": "Paragraph 2",
- "type": "text",
- },
- ],
- "type": "paragraph",
- },
- ],
- "type": "blockContainer",
- },
- ],
- "type": "column",
- },
- ],
- "type": "columnList",
- },
- ],
- "type": "blockGroup",
- },
- ],
- "type": "doc",
-}
-`;
-
-exports[`Test removeEmptyColumns > Two empty columns 1`] = `
-{
- "content": [
- {
- "content": [
- {
- "attrs": {
- "id": null,
- },
- "content": [
- {
- "attrs": {
- "id": null,
- "width": 1,
- },
- "content": [
- {
- "attrs": {
- "id": null,
- },
- "content": [
- {
- "attrs": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
- "type": "blockContainer",
- },
- ],
- "type": "column",
- },
- {
- "attrs": {
- "id": null,
- "width": 1,
- },
- "content": [
- {
- "attrs": {
- "id": null,
- },
- "content": [
- {
- "attrs": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
- "type": "blockContainer",
- },
- ],
- "type": "column",
- },
- ],
- "type": "columnList",
- },
- ],
- "type": "blockGroup",
- },
- ],
- "type": "doc",
-}
-`;
diff --git a/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap
new file mode 100644
index 0000000000..393ab225ed
--- /dev/null
+++ b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap
@@ -0,0 +1,183 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Test fixContainer drops emptied columns > First of two columns empty 1`] = `
+{
+ "content": [
+ {
+ "content": [
+ {
+ "attrs": {
+ "id": null,
+ },
+ "content": [
+ {
+ "attrs": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "content": [
+ {
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "type": "paragraph",
+ },
+ ],
+ "type": "blockContainer",
+ },
+ ],
+ "type": "blockGroup",
+ },
+ ],
+ "type": "doc",
+}
+`;
+
+exports[`Test fixContainer drops emptied columns > Last of two columns empty 1`] = `
+{
+ "content": [
+ {
+ "content": [
+ {
+ "attrs": {
+ "id": null,
+ },
+ "content": [
+ {
+ "attrs": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "content": [
+ {
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "type": "paragraph",
+ },
+ ],
+ "type": "blockContainer",
+ },
+ ],
+ "type": "blockGroup",
+ },
+ ],
+ "type": "doc",
+}
+`;
+
+exports[`Test fixContainer drops emptied columns > Start and end columns empty 1`] = `
+{
+ "content": [
+ {
+ "content": [
+ {
+ "attrs": {
+ "id": null,
+ },
+ "content": [
+ {
+ "attrs": {
+ "id": null,
+ "width": 1,
+ },
+ "content": [
+ {
+ "attrs": {
+ "id": null,
+ },
+ "content": [
+ {
+ "attrs": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "content": [
+ {
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "type": "paragraph",
+ },
+ ],
+ "type": "blockContainer",
+ },
+ ],
+ "type": "column",
+ },
+ {
+ "attrs": {
+ "id": null,
+ "width": 1,
+ },
+ "content": [
+ {
+ "attrs": {
+ "id": null,
+ },
+ "content": [
+ {
+ "attrs": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "content": [
+ {
+ "text": "Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "type": "paragraph",
+ },
+ ],
+ "type": "blockContainer",
+ },
+ ],
+ "type": "column",
+ },
+ ],
+ "type": "columnList",
+ },
+ ],
+ "type": "blockGroup",
+ },
+ ],
+ "type": "doc",
+}
+`;
+
+exports[`Test fixContainer drops emptied columns > Two empty columns 1`] = `
+{
+ "content": [
+ {
+ "content": [
+ {
+ "attrs": {
+ "id": null,
+ },
+ "content": [
+ {
+ "attrs": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "type": "blockContainer",
+ },
+ ],
+ "type": "blockGroup",
+ },
+ ],
+ "type": "doc",
+}
+`;
diff --git a/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts b/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts
deleted file mode 100644
index b5bd190c6d..0000000000
--- a/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts
+++ /dev/null
@@ -1,283 +0,0 @@
-import { describe, expect, it } from "vite-plus/test";
-
-import { setupTestEnv } from "../../setupTestEnv.js";
-import {
- fixColumnList,
- isEmptyColumn,
- removeEmptyColumns,
-} from "@blocknote/core";
-
-const getEditor = setupTestEnv();
-
-describe("Test isEmptyColumn", () => {
- it("Empty blocks", () => {
- const schema = getEditor()._tiptapEditor.schema;
-
- const column = schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(),
- ]),
- ]);
-
- expect(isEmptyColumn(column)).toBeTruthy();
- });
-
- it("Multiple blocks", () => {
- const schema = getEditor()._tiptapEditor.schema;
-
- const column = schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(undefined),
- ]),
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(),
- ]),
- ]);
-
- expect(isEmptyColumn(column)).toBeFalsy();
- });
-
- it("Block with children", () => {
- const schema = getEditor()._tiptapEditor.schema;
-
- const column = schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(undefined),
- schema.nodes["blockGroup"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(),
- ]),
- ]),
- ]),
- ]);
-
- expect(isEmptyColumn(column)).toBeFalsy();
- });
-
- it("Block with text", () => {
- const schema = getEditor()._tiptapEditor.schema;
-
- const column = schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(undefined, [
- schema.text("Paragraph 1"),
- ]),
- ]),
- ]);
-
- expect(isEmptyColumn(column)).toBeFalsy();
- });
-
- it("Non-text block", () => {
- const schema = getEditor()._tiptapEditor.schema;
-
- const column = schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["image"].create(),
- ]),
- ]);
-
- expect(isEmptyColumn(column)).toBeFalsy();
- });
-});
-
-describe("Test removeEmptyColumns", () => {
- it("Start and end columns empty", () => {
- const editor = getEditor();
- const schema = editor._tiptapEditor.schema;
-
- const columnList = schema.nodes["columnList"].create(undefined, [
- schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(),
- ]),
- ]),
- schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(undefined, [
- schema.text("Paragraph 1"),
- ]),
- ]),
- ]),
- schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(undefined, [
- schema.text("Paragraph 2"),
- ]),
- ]),
- ]),
- schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(),
- ]),
- ]),
- ]);
-
- const tr = editor.prosemirrorState.tr;
-
- tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- removeEmptyColumns(tr, 1);
-
- expect(tr.doc).toMatchSnapshot();
- });
-
- it("First of two columns empty", () => {
- const editor = getEditor();
- const schema = editor._tiptapEditor.schema;
-
- const columnList = schema.nodes["columnList"].create(undefined, [
- schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(),
- ]),
- ]),
- schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(undefined, [
- schema.text("Paragraph 1"),
- ]),
- ]),
- ]),
- ]);
-
- const tr = editor.prosemirrorState.tr;
-
- tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- removeEmptyColumns(tr, 1);
-
- expect(tr.doc).toMatchSnapshot();
- });
-
- it("Last of two columns empty", () => {
- const editor = getEditor();
- const schema = editor._tiptapEditor.schema;
-
- const columnList = schema.nodes["columnList"].create(undefined, [
- schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(undefined, [
- schema.text("Paragraph 1"),
- ]),
- ]),
- ]),
- schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(),
- ]),
- ]),
- ]);
-
- const tr = editor.prosemirrorState.tr;
-
- tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- removeEmptyColumns(tr, 1);
-
- expect(tr.doc).toMatchSnapshot();
- });
-
- it("Two empty columns", () => {
- const editor = getEditor();
- const schema = editor._tiptapEditor.schema;
-
- const columnList = schema.nodes["columnList"].create(undefined, [
- schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(),
- ]),
- ]),
- schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(),
- ]),
- ]),
- ]);
-
- const tr = editor.prosemirrorState.tr;
-
- tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- removeEmptyColumns(tr, 1);
-
- expect(tr.doc).toMatchSnapshot();
- });
-});
-
-describe("Test fixColumnList", () => {
- it("First of two columns empty", () => {
- const editor = getEditor();
- const schema = editor._tiptapEditor.schema;
-
- const columnList = schema.nodes["columnList"].create(undefined, [
- schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(),
- ]),
- ]),
- schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(undefined, [
- schema.text("Paragraph 1"),
- ]),
- ]),
- ]),
- ]);
-
- const tr = editor.prosemirrorState.tr;
-
- tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- fixColumnList(tr, 1);
-
- expect(tr.doc).toMatchSnapshot();
- });
-
- it("Last of two columns empty", () => {
- const editor = getEditor();
- const schema = editor._tiptapEditor.schema;
-
- const columnList = schema.nodes["columnList"].create(undefined, [
- schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(undefined, [
- schema.text("Paragraph 1"),
- ]),
- ]),
- ]),
- schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(),
- ]),
- ]),
- ]);
-
- const tr = editor.prosemirrorState.tr;
-
- tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- fixColumnList(tr, 1);
-
- expect(tr.doc).toMatchSnapshot();
- });
-
- it("Two empty columns", () => {
- const editor = getEditor();
- const schema = editor._tiptapEditor.schema;
-
- const columnList = schema.nodes["columnList"].create(undefined, [
- schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(),
- ]),
- ]),
- schema.nodes["column"].create(undefined, [
- schema.nodes["blockContainer"].create(undefined, [
- schema.nodes["paragraph"].create(),
- ]),
- ]),
- ]);
-
- const tr = editor.prosemirrorState.tr;
-
- tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- fixColumnList(tr, 1);
-
- expect(tr.doc).toMatchSnapshot();
- });
-});
diff --git a/packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts b/packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts
new file mode 100644
index 0000000000..2ae649678e
--- /dev/null
+++ b/packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { setupTestEnv } from "../../setupTestEnv.js";
+import { fixContainer, isEmptyContainerChild } from "@blocknote/core";
+
+const getEditor = setupTestEnv();
+
+describe("Test isEmptyContainerChild", () => {
+ it("Empty blocks", () => {
+ const schema = getEditor()._tiptapEditor.schema;
+
+ const column = schema.nodes["column"].create(undefined, [
+ schema.nodes["blockContainer"].create(undefined, [
+ schema.nodes["paragraph"].create(),
+ ]),
+ ]);
+
+ expect(isEmptyContainerChild(column)).toBeTruthy();
+ });
+
+ it("Multiple blocks", () => {
+ const schema = getEditor()._tiptapEditor.schema;
+
+ const column = schema.nodes["column"].create(undefined, [
+ schema.nodes["blockContainer"].create(undefined, [
+ schema.nodes["paragraph"].create(undefined),
+ ]),
+ schema.nodes["blockContainer"].create(undefined, [
+ schema.nodes["paragraph"].create(),
+ ]),
+ ]);
+
+ expect(isEmptyContainerChild(column)).toBeFalsy();
+ });
+
+ it("Block with children", () => {
+ const schema = getEditor()._tiptapEditor.schema;
+
+ const column = schema.nodes["column"].create(undefined, [
+ schema.nodes["blockContainer"].create(undefined, [
+ schema.nodes["paragraph"].create(undefined),
+ schema.nodes["blockGroup"].create(undefined, [
+ schema.nodes["blockContainer"].create(undefined, [
+ schema.nodes["paragraph"].create(),
+ ]),
+ ]),
+ ]),
+ ]);
+
+ expect(isEmptyContainerChild(column)).toBeFalsy();
+ });
+
+ it("Block with text", () => {
+ const schema = getEditor()._tiptapEditor.schema;
+
+ const column = schema.nodes["column"].create(undefined, [
+ schema.nodes["blockContainer"].create(undefined, [
+ schema.nodes["paragraph"].create(undefined, [
+ schema.text("Paragraph 1"),
+ ]),
+ ]),
+ ]);
+
+ expect(isEmptyContainerChild(column)).toBeFalsy();
+ });
+
+ it("Non-text block", () => {
+ const schema = getEditor()._tiptapEditor.schema;
+
+ const column = schema.nodes["column"].create(undefined, [
+ schema.nodes["blockContainer"].create(undefined, [
+ schema.nodes["image"].create(),
+ ]),
+ ]);
+
+ expect(isEmptyContainerChild(column)).toBeFalsy();
+ });
+});
+
+describe("Test fixContainer drops emptied columns", () => {
+ it.each<[string, string[]]>([
+ ["Start and end columns empty", ["", "Paragraph 1", "Paragraph 2", ""]],
+ ["First of two columns empty", ["", "Paragraph 1"]],
+ ["Last of two columns empty", ["Paragraph 1", ""]],
+ ["Two empty columns", ["", ""]],
+ ])("%s", (_name, texts) => {
+ const editor = getEditor();
+ const schema = editor._tiptapEditor.schema;
+ const columnList = schema.nodes["columnList"].create(
+ undefined,
+ texts.map((text) =>
+ schema.nodes["column"].create(undefined, [
+ schema.nodes["blockContainer"].create(undefined, [
+ schema.nodes["paragraph"].create(
+ undefined,
+ text ? schema.text(text) : undefined,
+ ),
+ ]),
+ ]),
+ ),
+ );
+ const tr = editor.prosemirrorState.tr;
+ tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
+ fixContainer(tr, 1);
+ expect(tr.doc).toMatchSnapshot();
+ });
+});
diff --git a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html
index 2237513b6b..78534582b8 100644
--- a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html
+++ b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html
@@ -1 +1 @@
-Column Paragraph 0
Column Paragraph 1
Column Paragraph 2
Column Paragraph 3
\ No newline at end of file
+Column Paragraph 0
Column Paragraph 1
Column Paragraph 2
Column Paragraph 3
\ No newline at end of file
diff --git a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html
index 5876b3bd03..0d6612056e 100644
--- a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html
+++ b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html
@@ -1 +1 @@
-Column Paragraph 0
Column Paragraph 1
Column Paragraph 2
Column Paragraph 3
\ No newline at end of file
+Column Paragraph 0
Column Paragraph 1
Column Paragraph 2
Column Paragraph 3
\ No newline at end of file
diff --git a/packages/xl-multi-column/src/test/extensions/columnDrop.test.ts b/packages/xl-multi-column/src/test/extensions/columnDrop.test.ts
new file mode 100644
index 0000000000..1a2ed28305
--- /dev/null
+++ b/packages/xl-multi-column/src/test/extensions/columnDrop.test.ts
@@ -0,0 +1,95 @@
+import { getNodeById } from "@blocknote/core";
+import { Fragment, Slice } from "prosemirror-model";
+import { describe, expect, it, vi } from "vite-plus/test";
+
+import { createMultiColumnHandleDropPlugin } from "../../extensions/DropCursor/multiColumnHandleDropPlugin.js";
+import { detectEdgePosition } from "../../extensions/DropCursor/multiColumnDropCursor.js";
+import { setupTestEnv } from "../setupTestEnv.js";
+
+vi.mock("../../extensions/DropCursor/multiColumnDropCursor.js", () => ({
+ detectEdgePosition: vi.fn(),
+}));
+
+const getEditor = setupTestEnv();
+
+function dropOnColumn(
+ ids: string[],
+ target: string,
+ position: "left" | "right",
+) {
+ const editor = getEditor();
+ const view = editor.prosemirrorView;
+ const column = getNodeById(target, view.state.doc)!;
+ vi.mocked(detectEdgePosition).mockReturnValue({ ...column, position });
+ const nodes = ids.map((id) => getNodeById(id, view.state.doc)!.node);
+ const plugin = createMultiColumnHandleDropPlugin(editor);
+ return plugin.props.handleDrop!.call(
+ plugin,
+ view,
+ Object.assign(new MouseEvent("drop"), { dataTransfer: null }),
+ new Slice(Fragment.from(nodes), 0, 0),
+ true,
+ );
+}
+
+describe("column edge drops", () => {
+ it.each(["left", "right"] as const)(
+ "moves a mixed selection to an emptied target's %s edge",
+ (edge) => {
+ const editor = getEditor();
+ editor.insertBlocks(
+ [
+ {
+ id: "column-last",
+ type: "column",
+ children: [{ id: "last", type: "paragraph", content: "Last" }],
+ },
+ ],
+ "column-1",
+ "after",
+ );
+ const dragged = [
+ "column-paragraph-1",
+ "column-paragraph-2",
+ "column-paragraph-3",
+ ];
+ expect(dropOnColumn(dragged, "column-1", edge)).toBe(true);
+ expect(
+ editor
+ .getBlock("column-list-0")!
+ .children.map((column) => column.children.map((block) => block.id)),
+ ).toEqual([["column-paragraph-0"], dragged, ["last"]]);
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ },
+ );
+
+ it("preserves the column when dropping only its own children", () => {
+ const editor = getEditor();
+ const before = editor.document;
+ dropOnColumn(
+ ["column-paragraph-0", "column-paragraph-1"],
+ "column-0",
+ "right",
+ );
+ expect(editor.document).toEqual(before);
+ });
+
+ it("unwraps the layout when all its blocks are dropped together", () => {
+ const editor = getEditor();
+ const dragged = [
+ "column-paragraph-0",
+ "column-paragraph-1",
+ "column-paragraph-2",
+ "column-paragraph-3",
+ ];
+ dropOnColumn(dragged, "column-0", "left");
+ expect(editor.getBlock("column-list-0")).toBeUndefined();
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "paragraph-0",
+ "paragraph-1",
+ ...dragged,
+ "paragraph-2",
+ ]);
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+});
diff --git a/packages/xl-multi-column/src/test/extensions/columnResize.test.ts b/packages/xl-multi-column/src/test/extensions/columnResize.test.ts
new file mode 100644
index 0000000000..c8cf8e909b
--- /dev/null
+++ b/packages/xl-multi-column/src/test/extensions/columnResize.test.ts
@@ -0,0 +1,176 @@
+import { getNodeById } from "@blocknote/core";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ ColumnState,
+ columnResizePluginKey,
+} from "../../extensions/ColumnResize/ColumnResizeExtension.js";
+import { setupTestEnv } from "../setupTestEnv.js";
+
+const getEditor = setupTestEnv();
+
+// Puts the column resize plugin into the state it would be in when the user
+// hovers the boundary between the two columns of "column-list-0" in the test
+// document, as the plugin's mouse handlers would.
+function hoverColumnBoundary() {
+ const editor = getEditor();
+ const view = editor._tiptapEditor.view;
+
+ const columnList = getNodeById("column-list-0", view.state.doc);
+ const leftColumn = getNodeById("column-0", view.state.doc);
+ const rightColumn = getNodeById("column-1", view.state.doc);
+
+ if (!columnList || !leftColumn || !rightColumn) {
+ throw new Error("Test document is missing expected columns");
+ }
+
+ const hoverState: ColumnState = {
+ type: "hover-column",
+ columnList: {
+ element: document.createElement("div"),
+ id: "column-list-0",
+ ...columnList,
+ },
+ leftColumn: {
+ element: document.createElement("div"),
+ id: "column-0",
+ ...leftColumn,
+ },
+ rightColumn: {
+ element: document.createElement("div"),
+ id: "column-1",
+ ...rightColumn,
+ },
+ };
+
+ view.dispatch(view.state.tr.setMeta(columnResizePluginKey, hoverState));
+}
+
+describe("Column resize plugin state after doc changes", () => {
+ it("falls back to default when a hovered column's removal unwraps the column list", () => {
+ const editor = getEditor();
+
+ hoverColumnBoundary();
+
+ // Removing one of the two columns brings the column list below its
+ // minimum of 2 children, so it gets unwrapped entirely. This used to
+ // throw a RangeError from the plugin's decorations, as they were built
+ // from positions resolved against the old, larger doc.
+ editor.removeBlocks(["column-1"]);
+
+ expect(
+ columnResizePluginKey.getState(editor._tiptapEditor.view.state),
+ ).toEqual({ type: "default" });
+ // The surviving column's two paragraphs are unwrapped to the top level.
+ expect(editor.document.map((block) => block.type)).toEqual([
+ "paragraph",
+ "paragraph",
+ "paragraph",
+ "paragraph",
+ "paragraph",
+ ]);
+ });
+
+ it("falls back to default when the whole doc is replaced", () => {
+ const editor = getEditor();
+
+ hoverColumnBoundary();
+
+ // Mimics select-all + backspace clearing the document while columns are
+ // hovered.
+ editor.replaceBlocks(editor.document, [{ type: "paragraph" }]);
+
+ expect(
+ columnResizePluginKey.getState(editor._tiptapEditor.view.state),
+ ).toEqual({ type: "default" });
+ expect(editor.document).toHaveLength(1);
+ });
+
+ it("keeps the hover state when an unrelated block changes", () => {
+ const editor = getEditor();
+
+ hoverColumnBoundary();
+
+ editor.updateBlock("paragraph-1", { content: "Updated Paragraph 1" });
+
+ const pluginState = columnResizePluginKey.getState(
+ editor._tiptapEditor.view.state,
+ );
+ expect(pluginState?.type).toBe("hover-column");
+ });
+});
+
+describe.each(["hover-column", "resize"] as const)(
+ "%s pair validation",
+ (mode) => {
+ function startPair() {
+ hoverColumnBoundary();
+ if (mode === "resize") {
+ const view = getEditor().prosemirrorView;
+ const state = columnResizePluginKey.getState(view.state);
+ if (state?.type !== "hover-column") {
+ throw new Error("Expected a hovered column pair");
+ }
+ const resize: ColumnState = {
+ ...state,
+ type: "resize",
+ startPos: 0,
+ leftColumn: { ...state.leftColumn, widthPx: 100, widthPercent: 1 },
+ rightColumn: { ...state.rightColumn, widthPx: 100, widthPercent: 1 },
+ };
+ view.dispatch(view.state.tr.setMeta(columnResizePluginKey, resize));
+ }
+ }
+
+ it.each(["reorder", "separate", "reparent"] as const)(
+ "clears the pair after %s",
+ (change) => {
+ const editor = getEditor();
+ startPair();
+ const columns = editor.getBlock("column-list-0")!.children;
+ const extra = {
+ id: "extra-column",
+ type: "column" as const,
+ children: [{ type: "paragraph" as const, content: "Extra" }],
+ };
+ if (change === "reorder") {
+ editor.updateBlock("column-list-0", {
+ children: [columns[1], columns[0]],
+ });
+ } else if (change === "separate") {
+ editor.updateBlock("column-list-0", {
+ children: [columns[0], extra, columns[1]],
+ });
+ } else {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "columnList",
+ id: "column-list-0",
+ children: [columns[0], extra],
+ },
+ {
+ type: "columnList",
+ id: "other-list",
+ children: [
+ columns[1],
+ { type: "column", children: [{ type: "paragraph" }] },
+ ],
+ },
+ ]);
+ }
+ expect(columnResizePluginKey.getState(editor.prosemirrorState)).toEqual(
+ { type: "default" },
+ );
+ },
+ );
+
+ it("keeps an adjacent pair after an unrelated edit", () => {
+ const editor = getEditor();
+ startPair();
+ editor.updateBlock("paragraph-1", { content: "Changed" });
+ expect(
+ columnResizePluginKey.getState(editor.prosemirrorState)?.type,
+ ).toBe(mode);
+ });
+ },
+);
diff --git a/packages/xl-odt-exporter/src/odt/odtExporter.test.ts b/packages/xl-odt-exporter/src/odt/odtExporter.test.ts
index 4883fd0387..a6681e6917 100644
--- a/packages/xl-odt-exporter/src/odt/odtExporter.test.ts
+++ b/packages/xl-odt-exporter/src/odt/odtExporter.test.ts
@@ -1,9 +1,12 @@
import {
BlockNoteSchema,
+ createBlockSpec,
createPageBreakBlockSpec,
defaultBlockSpecs,
} from "@blocknote/core";
import { testODTDocumentAgainstSnapshot } from "@shared/util/odtTestUtil.js";
+import { partialBlocksToBlocksForTesting } from "@shared/formatConversionTestUtil.js";
+import { BlobReader, FileEntry, TextWriter, ZipReader } from "@zip.js/zip.js";
import { testDocument } from "@shared/testDocument.js";
import { beforeAll, describe, expect, it } from "vite-plus/test";
import { createElement } from "react";
@@ -101,3 +104,113 @@ describe("exporter", () => {
);
});
});
+
+describe("titled blocks", () => {
+ // A titled block: inline content (the title) plus children (the body). The
+ // mapping renders the title and places the children inside its own
+ // section; because the block counts as a container, transformBlocks must
+ // not append them after it as tab-indented siblings.
+ const Alert = createBlockSpec(
+ {
+ type: "alert" as const,
+ propSchema: {},
+ content: "inline",
+ children: { allow: "blocks" },
+ },
+ {
+ render: (block: any) => {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-node-type", "alert");
+ dom.setAttribute("data-id", block.id);
+ return { dom, contentDOM: dom };
+ },
+ renderFrame: (_block: any) => {
+ const dom = document.createElement("div");
+ dom.className = "alert-box";
+ return { dom, slot: dom };
+ },
+ },
+ )();
+
+ const alertSchema = BlockNoteSchema.create({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ alert: Alert,
+ },
+ });
+
+ const alertDocument = partialBlocksToBlocksForTesting(alertSchema, [
+ {
+ type: "alert",
+ content: "Heads up",
+ children: [
+ { type: "paragraph", content: "First" },
+ { type: "paragraph", content: "Second" },
+ ],
+ },
+ ] as any);
+
+ it("throws a clear error for an unmapped container block", async () => {
+ const exporter = new ODTExporter(
+ alertSchema,
+ odtDefaultSchemaMappings as any,
+ { resolveFileUrl: testResolveFileUrl },
+ );
+
+ await expect(
+ exporter.transformBlocks(alertDocument as any),
+ ).rejects.toThrow(/container block type "alert"/);
+ });
+
+ it("renders a titled block's title and places its children inside", async () => {
+ const exporter = new ODTExporter(
+ alertSchema,
+ {
+ ...odtDefaultSchemaMappings,
+ blockMapping: {
+ ...odtDefaultSchemaMappings.blockMapping,
+ alert: (
+ block: any,
+ exporter: any,
+ _nesting: any,
+ _index: any,
+ children: any,
+ ) =>
+ createElement(
+ "text:section",
+ { "text:name": "alert-body" },
+ createElement(
+ "text:p",
+ null,
+ "ALERT:",
+ ...exporter.transformInlineContent(block.content),
+ ),
+ ...((children ?? []) as any[]),
+ ),
+ },
+ } as any,
+ { resolveFileUrl: testResolveFileUrl },
+ );
+
+ const odt = await exporter.toODTDocument(alertDocument as any);
+ const entries = await new ZipReader(new BlobReader(odt)).getEntries();
+ const contentEntry = entries.find(
+ (entry) => entry.filename === "content.xml",
+ ) as FileEntry;
+ expect(contentEntry).toBeDefined();
+ const xml = await contentEntry.getData(new TextWriter());
+
+ // Title and children all sit inside the mapping's own section - placed
+ // by the mapping, not appended after it.
+ const sectionOpen = xml.indexOf("");
+ expect(sectionOpen).toBeGreaterThan(-1);
+ const titleIdx = xml.indexOf("Heads up");
+ expect(titleIdx).toBeGreaterThan(sectionOpen);
+ const firstIdx = xml.indexOf(">First<");
+ expect(firstIdx).toBeGreaterThan(titleIdx);
+ const secondIdx = xml.indexOf(">Second<");
+ expect(secondIdx).toBeGreaterThan(firstIdx);
+ expect(secondIdx).toBeLessThan(sectionClose);
+ });
+});
diff --git a/packages/xl-odt-exporter/src/odt/odtExporter.tsx b/packages/xl-odt-exporter/src/odt/odtExporter.tsx
index fc72dc1be0..f254a4fc63 100644
--- a/packages/xl-odt-exporter/src/odt/odtExporter.tsx
+++ b/packages/xl-odt-exporter/src/odt/odtExporter.tsx
@@ -59,7 +59,7 @@ export class ODTExporter<
public readonly options: ExporterOptions;
constructor(
- protected readonly schema: BlockNoteSchema,
+ schema: BlockNoteSchema,
mappings: Exporter<
NoInfer,
NoInfer,
@@ -155,32 +155,23 @@ export class ODTExporter<
numberedListIndex = 0;
}
- if (["columnList", "column"].includes(block.type)) {
- const children = await this.transformBlocks(block.children, 0);
- const content = await this.mapBlock(
- block as any,
- 0,
- numberedListIndex,
- children,
- );
-
- ret.push(content);
- } else {
- const children = await this.transformBlocks(
- block.children,
- nestingLevel + 1,
- );
- const content = await this.mapBlock(
- block as any,
- nestingLevel,
+ const isContainer = this.isContainerBlock(block);
+ // Container mappings own the layout: table cells start a fresh
+ // indentation context instead of inheriting literal s.
+ const children = await this.transformBlocks(
+ block.children,
+ isContainer ? 0 : nestingLevel + 1,
+ );
+ ret.push(
+ await this.mapBlock(
+ block,
+ isContainer ? 0 : nestingLevel,
numberedListIndex,
children,
- );
-
- ret.push(content);
- if (children.length > 0) {
- ret.push(...children);
- }
+ ),
+ );
+ if (!isContainer) {
+ ret.push(...children);
}
}
diff --git a/packages/xl-pdf-exporter/src/react-pdf/pdfExporter.test.tsx b/packages/xl-pdf-exporter/src/react-pdf/pdfExporter.test.tsx
index 8be64289e2..22364d427d 100644
--- a/packages/xl-pdf-exporter/src/react-pdf/pdfExporter.test.tsx
+++ b/packages/xl-pdf-exporter/src/react-pdf/pdfExporter.test.tsx
@@ -1,11 +1,14 @@
import {
BlockNoteSchema,
+ createBlockSpec,
createPageBreakBlockSpec,
defaultBlockSpecs,
} from "@blocknote/core";
import { ColumnBlock, ColumnListBlock } from "@blocknote/xl-multi-column";
-import { Text } from "@react-pdf/renderer";
+import { Text, View } from "@react-pdf/renderer";
+import { partialBlocksToBlocksForTesting } from "@shared/formatConversionTestUtil.js";
import { testDocument } from "@shared/testDocument.js";
+import { Fragment } from "react";
import reactElementToJSXString from "react-element-to-jsx-string";
import { describe, expect, it } from "vite-plus/test";
import { pdfDefaultSchemaMappings } from "./defaultSchema/index.js";
@@ -59,3 +62,98 @@ describe("exporter", () => {
);
});
});
+
+describe("titled blocks", () => {
+ // A titled block: inline content (the title) plus children (the body). The
+ // mapping renders the title and places the children inside its own view;
+ // because the block counts as a container, transformBlocks must not wrap
+ // them after it in padded sibling views.
+ const Alert = createBlockSpec(
+ {
+ type: "alert" as const,
+ propSchema: {},
+ content: "inline",
+ children: { allow: "blocks" },
+ },
+ {
+ render: (block: any) => {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-node-type", "alert");
+ dom.setAttribute("data-id", block.id);
+ return { dom, contentDOM: dom };
+ },
+ renderFrame: (_block: any) => {
+ const dom = document.createElement("div");
+ dom.className = "alert-box";
+ return { dom, slot: dom };
+ },
+ },
+ )();
+
+ const alertSchema = BlockNoteSchema.create({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ alert: Alert,
+ },
+ });
+
+ const alertDocument = partialBlocksToBlocksForTesting(alertSchema, [
+ {
+ type: "alert",
+ content: "Heads up",
+ children: [
+ { type: "paragraph", content: "First" },
+ { type: "paragraph", content: "Second" },
+ ],
+ },
+ ] as any);
+
+ it("throws a clear error for an unmapped container block", async () => {
+ const exporter = new PDFExporter(
+ alertSchema,
+ pdfDefaultSchemaMappings as any,
+ );
+
+ await expect(
+ exporter.transformBlocks(alertDocument as any),
+ ).rejects.toThrow(/container block type "alert"/);
+ });
+
+ it("renders a titled block's title and places its children inside", async () => {
+ const exporter = new PDFExporter(alertSchema, {
+ ...pdfDefaultSchemaMappings,
+ blockMapping: {
+ ...pdfDefaultSchemaMappings.blockMapping,
+ alert: (
+ block: any,
+ exporter: any,
+ _nestingLevel: any,
+ _numberedListIndex: any,
+ children: any,
+ ) => (
+
+ ALERT:{exporter.transformInlineContent(block.content)}
+ {children}
+
+ ),
+ },
+ } as any);
+
+ const transformed = await exporter.transformBlocks(alertDocument as any);
+ const str = reactElementToJSXString(
+ {transformed as any} ,
+ );
+
+ // The mapping's own view holds the title and both children - the block
+ // took the container branch, so no marginLeft wrapper view was added
+ // around the children (react-element-to-jsx-string prints the primitives
+ // upper case, as in the document snapshots). The children themselves
+ // arrive pre-wrapped in their usual padded views, exactly as column
+ // children do.
+ expect(str).not.toContain("marginLeft");
+ const titleIdx = str.indexOf("Heads up");
+ expect(titleIdx).toBeGreaterThan(-1);
+ expect(str.indexOf("First")).toBeGreaterThan(titleIdx);
+ expect(str).toContain("Second");
+ });
+});
diff --git a/packages/xl-pdf-exporter/src/react-pdf/pdfExporter.tsx b/packages/xl-pdf-exporter/src/react-pdf/pdfExporter.tsx
index f6242c0bb7..5ff077fa6d 100644
--- a/packages/xl-pdf-exporter/src/react-pdf/pdfExporter.tsx
+++ b/packages/xl-pdf-exporter/src/react-pdf/pdfExporter.tsx
@@ -106,7 +106,7 @@ export class PDFExporter<
/**
* The schema of your editor. The mappings are automatically typed checked against this schema.
*/
- protected readonly schema: BlockNoteSchema,
+ schema: BlockNoteSchema,
/**
* The mappings that map the BlockNote schema to the react-pdf content.
*
@@ -178,13 +178,13 @@ export class PDFExporter<
}
const children = await this.transformBlocks(b.children, nestingLevel + 1);
const self = await this.mapBlock(
- b as any,
+ b,
nestingLevel,
numberedListIndex,
children,
- ); // TODO: any
+ );
- if (["pageBreak", "columnList", "column"].includes(b.type)) {
+ if (b.type === "pageBreak" || this.isContainerBlock(b)) {
ret.push(self);
continue;
}
diff --git a/packages/xl-typst-exporter/src/__snapshots__/testDocument.typ b/packages/xl-typst-exporter/src/__snapshots__/testDocument.typ
index 7beb8b3135..51eeea8aab 100644
--- a/packages/xl-typst-exporter/src/__snapshots__/testDocument.typ
+++ b/packages/xl-typst-exporter/src/__snapshots__/testDocument.typ
@@ -169,19 +169,20 @@
#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#strong("Some inline code: ")#raw("var foo = 'bar';")]
-#grid(
- columns: (0.8fr, 1.4fr, 0.8fr),
- column-gutter: 1em,
- [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"This paragraph is in a column!"]],
- [#block(width: 100%, inset: (top: (8pt + 6.9pt), bottom: 6.9pt))[#heading(level: 1, outlined: true)[#"So is this heading!"]]],
- [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"You can have multiple blocks in a column too"]
+#{
+ let cols = (
+ (width: 0.8, body: [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"This paragraph is in a column!"]]),
+ (width: 1.4, body: [#block(width: 100%, inset: (top: (8pt + 6.9pt), bottom: 6.9pt))[#heading(level: 1, outlined: true)[#"So is this heading!"]]]),
+ (width: 0.8, body: [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"You can have multiple blocks in a column too"]
#list(
[#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Block 1"]],
[#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Block 2"]],
[#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Block 3"]]
-)]
-)
+)]),
+ )
+ grid(columns: cols.map(c => c.width * 1fr), column-gutter: 1em, ..cols.map(c => c.body))
+}
#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#line(length: 100%, stroke: 1pt + rgb("#7D797A"))]
diff --git a/packages/xl-typst-exporter/src/defaultSchema/blocks.ts b/packages/xl-typst-exporter/src/defaultSchema/blocks.ts
index daeb3733bf..12dc1349da 100644
--- a/packages/xl-typst-exporter/src/defaultSchema/blocks.ts
+++ b/packages/xl-typst-exporter/src/defaultSchema/blocks.ts
@@ -159,11 +159,36 @@ export const typstBlockMappingForDefaultSchema: BlockMapping<
divider: () => `#line(length: 100%, stroke: 1pt + rgb("#7D797A"))`,
pageBreak: () => `#pagebreak(weak: true)`,
- // Multi-column layout is assembled by TypstExporter.transformBlocks (columns
- // become grid cells). These entries exist only to satisfy the BlockMapping
- // type — they are never invoked.
- column: () => "",
- columnList: () => "",
+ // A column only ever exists as a columnList's child (its config is
+ // `placeable: "namedOnly"`), so rather than content it returns the
+ // Typst *value* its parent needs: the width and the cell body. Typst takes
+ // a grid's track sizes on the grid, not on the cell, so the width has to
+ // reach the parent - the same reason the DOCX mapping hands its columnList
+ // a width-carrying table cell.
+ column: (block, _exporter, _nestingLevel, _numberedListIndex, children) =>
+ `(width: ${block.props.width ?? 1}, body: [${(children ?? []).join("\n\n")}])`,
+
+ // Lays the columns out side-by-side as a Typst `grid`, assembled in Typst
+ // code from the columns' (width, body) pairs so the fractional (`fr`)
+ // tracks keep their relative sizes. `grid` is a layout primitive (not a
+ // `table`), so it isn't tagged as a data table in the PDF. The trailing
+ // comma matters: `(x)` in Typst is a parenthesized value, not a one-element
+ // array.
+ columnList: (
+ _block,
+ _exporter,
+ _nestingLevel,
+ _numberedListIndex,
+ children,
+ ) =>
+ [
+ `#{`,
+ ` let cols = (`,
+ ...(children ?? []).map((c) => ` ${c},`),
+ ` )`,
+ ` grid(columns: cols.map(c => c.width * 1fr), column-gutter: 1em, ..cols.map(c => c.body))`,
+ `}`,
+ ].join("\n"),
// --- media -> Figure + Alt --------------------------------------------------
image: (block, exporter) =>
diff --git a/packages/xl-typst-exporter/src/typstExporter.test.ts b/packages/xl-typst-exporter/src/typstExporter.test.ts
index 890545d4d2..5ed9e05e0b 100644
--- a/packages/xl-typst-exporter/src/typstExporter.test.ts
+++ b/packages/xl-typst-exporter/src/typstExporter.test.ts
@@ -1,5 +1,6 @@
import {
BlockNoteSchema,
+ createBlockSpec,
createPageBreakBlockSpec,
defaultBlockSpecs,
} from "@blocknote/core";
@@ -29,6 +30,101 @@ const fullSchema = BlockNoteSchema.create({
},
});
+// A minimal custom container, standing in for a callout/card: content-less,
+// holding child blocks. Its mapping is what has to place them.
+const Box = createBlockSpec(
+ {
+ type: "box" as const,
+ propSchema: {},
+ content: "none",
+ children: { allow: "blocks" },
+ },
+ {
+ render: (block: any) => {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-node-type", "box");
+ dom.setAttribute("data-id", block.id);
+ return { dom, contentDOM: dom };
+ },
+ },
+)();
+
+const boxSchema = BlockNoteSchema.create({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ box: Box,
+ },
+});
+
+const boxDocument = partialBlocksToBlocksForTesting(boxSchema, [
+ {
+ type: "box",
+ children: [
+ { type: "paragraph", content: "First" },
+ { type: "paragraph", content: "Second" },
+ ],
+ },
+] as any);
+
+// A titled block: inline content (the title) plus children (the body). The
+// schema build marks it as a titled block rather than a container node, but
+// for export the contract is the same - the mapping renders the title and
+// places the children, so they must arrive as the mapping's `children` arg.
+const Alert = createBlockSpec(
+ {
+ type: "alert" as const,
+ propSchema: {},
+ content: "inline",
+ children: { allow: "blocks" },
+ },
+ {
+ render: (block: any) => {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-node-type", "alert");
+ dom.setAttribute("data-id", block.id);
+ return { dom, contentDOM: dom };
+ },
+ renderFrame: (_block: any) => {
+ const dom = document.createElement("div");
+ dom.className = "alert-box";
+ return { dom, slot: dom };
+ },
+ },
+)();
+
+const alertSchema = BlockNoteSchema.create({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ alert: Alert,
+ },
+});
+
+const alertDocument = partialBlocksToBlocksForTesting(alertSchema, [
+ {
+ type: "alert",
+ content: "Heads up",
+ children: [
+ { type: "paragraph", content: "First" },
+ { type: "paragraph", content: "Second" },
+ ],
+ },
+] as any);
+
+const alertMappings = {
+ ...typstDefaultSchemaMappings,
+ blockMapping: {
+ ...typstDefaultSchemaMappings.blockMapping,
+ alert: (
+ block: any,
+ exporter: any,
+ _nestingLevel: any,
+ _numberedListIndex: any,
+ children?: string[],
+ ) =>
+ `#rect[#strong[${exporter.transformInlineContent(block.content).join("")}]\n\n${(children ?? []).join("\n\n")}]`,
+ },
+} as any;
+
describe("TypstExporter", () => {
it("exports a real BlockNote document to Typst", async () => {
// fullSchema (incl. multi-column) matches the shared testDocument. The
@@ -435,3 +531,94 @@ describe("TypstExporter", () => {
).toHaveLength(0);
});
});
+
+describe("container blocks", () => {
+ it("lays a columnList out as a grid with proportional tracks", async () => {
+ const exporter = new TypstExporter(fullSchema, typstDefaultSchemaMappings);
+
+ const typ = await exporter.toTypst(
+ partialBlocksToBlocksForTesting(fullSchema, [
+ {
+ type: "columnList",
+ children: [
+ {
+ type: "column",
+ props: { width: 2 },
+ children: [{ type: "paragraph", content: "Left" }],
+ },
+ {
+ type: "column",
+ children: [{ type: "paragraph", content: "Right" }],
+ },
+ ],
+ },
+ ] as any),
+ );
+
+ // Each column contributes its width and body; the grid derives its
+ // fractional tracks from them, so the relative sizes survive.
+ expect(typ).toContain("(width: 2, body: [");
+ expect(typ).toContain("(width: 1, body: [");
+ expect(typ).toContain(
+ "grid(columns: cols.map(c => c.width * 1fr), column-gutter: 1em, ..cols.map(c => c.body))",
+ );
+ // A layout grid, not a `table` - it must not be tagged as a data table.
+ expect(typ).not.toContain("#table(");
+
+ // The assembled markup is real Typst, not just a plausible string.
+ await compileTypstForTesting(typ, { assets: exporter.assetFiles });
+ });
+
+ it("passes its children to a custom container's mapping", async () => {
+ const typ = await new TypstExporter(boxSchema, {
+ ...typstDefaultSchemaMappings,
+ blockMapping: {
+ ...typstDefaultSchemaMappings.blockMapping,
+ box: (
+ _block: any,
+ _exporter: any,
+ _nestingLevel: any,
+ _numberedListIndex: any,
+ children?: string[],
+ ) => `#rect[${(children ?? []).join("\n\n")}]`,
+ },
+ } as any).toTypst(boxDocument);
+
+ // The children sit *inside* the container's own output - the mapping
+ // owns their placement - rather than following it as an indented run.
+ expect(typ).toContain("#rect[");
+ expect(typ.indexOf('#"First"')).toBeGreaterThan(typ.indexOf("#rect["));
+ expect(typ).toContain('#"Second"');
+ expect(typ).not.toContain("#pad(left: 1.5em)");
+ });
+
+ it("throws a clear error for an unmapped container block", async () => {
+ // The missing `box` mapping is the point of the test, and it's exactly
+ // what `BlockMapping` refuses to type - hence the cast (as in the DOCX
+ // exporter's equivalent test).
+ const exporter = new TypstExporter(
+ boxSchema,
+ typstDefaultSchemaMappings as any,
+ );
+
+ await expect(exporter.toTypst(boxDocument)).rejects.toThrow(
+ /container block type "box"/,
+ );
+ });
+
+ it("renders a titled block's title and places its children inside", async () => {
+ const typ = await new TypstExporter(alertSchema, alertMappings).toTypst(
+ alertDocument,
+ );
+
+ // The title sits inside the container's own output, and the children
+ // follow it there - placed by the mapping, not appended after as an
+ // indented run.
+ expect(typ).toContain("#rect[");
+ const titleIdx = typ.indexOf('#"Heads up"');
+ expect(titleIdx).toBeGreaterThan(typ.indexOf("#rect["));
+ expect(typ.indexOf('#"First"')).toBeGreaterThan(titleIdx);
+ expect(typ).toContain('#"Second"');
+ expect(typ).not.toContain("#pad(left: 1.5em)");
+ });
+});
diff --git a/packages/xl-typst-exporter/src/typstExporter.ts b/packages/xl-typst-exporter/src/typstExporter.ts
index 1c8f6af33c..f45a27105c 100644
--- a/packages/xl-typst-exporter/src/typstExporter.ts
+++ b/packages/xl-typst-exporter/src/typstExporter.ts
@@ -253,23 +253,15 @@ export class TypstExporter<
continue;
}
- // A columnList lays its column children out side-by-side. transformBlocks
- // owns this (rather than the block mapping) because the columns must
- // become grid cells, not the generic indented-children wrapper.
- if (b.type === "columnList") {
- out.push(await this.renderColumnList(b, nestingLevel));
- i++;
- continue;
- }
-
+ const isContainer = this.isContainerBlock(b);
const children = await this.transformBlocks(b.children, nestingLevel + 1);
- const self = (await this.mapBlock(
- b as any,
+ const self = await this.mapBlock(
+ b,
nestingLevel,
0,
- [],
- )) as string;
- out.push(this.wrapBlock(b, self, children));
+ isContainer ? children : [],
+ );
+ out.push(isContainer ? self : this.wrapBlock(b, self, children));
i++;
}
return out;
@@ -279,12 +271,7 @@ export class TypstExporter<
block: Block,
nestingLevel: number,
): Promise {
- const body = (await this.mapBlock(
- block as any,
- nestingLevel,
- 0,
- [],
- )) as string;
+ const body = await this.mapBlock(block, nestingLevel, 0, []);
const children = await this.transformBlocks(
block.children,
nestingLevel + 1,
@@ -324,32 +311,6 @@ export class TypstExporter<
);
}
- /**
- * Render a columnList as a Typst `grid`: each child column becomes a grid
- * cell, its `width` prop mapped to a fractional (`fr`) track so relative
- * column sizes are preserved. `grid` is a layout primitive (not a `table`),
- * so it isn't tagged as a data table in the PDF.
- */
- private async renderColumnList(
- block: Block,
- nestingLevel: number,
- ): Promise {
- const columns = block.children;
- const tracks = columns
- .map((c) => `${(c.props as { width?: number }).width ?? 1}fr`)
- .join(", ");
- const cells: string[] = [];
- for (const col of columns) {
- const inner = (
- await this.transformBlocks(col.children, nestingLevel)
- ).join("\n\n");
- cells.push(`[${inner}]`);
- }
- return `#grid(\n columns: (${tracks}),\n column-gutter: 1em,\n ${cells.join(
- ",\n ",
- )}\n)`;
- }
-
private wrapList(
kind: "bullet" | "numbered" | "check",
items: string[],
diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx
index 2d32e8c524..2e793bfe93 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -1487,6 +1487,33 @@ export const examples = {
readme:
"In this example, we create a custom block which renders a simple HTML paragraph with placeholder text. The block has no editable content.\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)",
},
+ {
+ projectSlug: "container-block",
+ fullSlug: "custom-schema/container-block",
+ pathFromRoot: "examples/06-custom-schema/09-container-block",
+ config: {
+ playground: true,
+ docs: true,
+ author: "nickthesick",
+ tags: [
+ "Intermediate",
+ "Blocks",
+ "Custom Schemas",
+ "Suggestion Menus",
+ "Slash Menu",
+ ],
+ dependencies: {
+ "react-icons": "^5.5.0",
+ } as any,
+ },
+ title: "Container Block",
+ group: {
+ pathFromRoot: "examples/06-custom-schema",
+ slug: "custom-schema",
+ },
+ readme:
+ 'In this example, we create a custom `Panel` block that holds other blocks as its body, like a Notion-style callout wrapping a paragraph followed by a code block.\n\nThe block declares the `children` config on `BlockConfig`. `children: { allow: "blocks" }` makes it a container: its child blocks mount into the frame\'s `slot` (attached with `ref={contentRef}`), and live on `block.children` at runtime. A pure container like this draws its box in `renderFrame` alone, which re-renders live when props change — click the icon to cycle the panel\'s flavor and watch the box follow without rebuilding the body.\n\nWe also wire up a Slash Menu item to insert the panel, and render the document JSON next to the editor so you can inspect the structure of the nested blocks.\n\n**Try it out:**\n\n- Press the "/" key inside the panel\'s body and add a code block, heading, or list.\n- Click the panel\'s icon to cycle its flavor. The box re-renders in place; the children are untouched.\n- Watch the JSON panel on the right update as you edit; the panel\'s children appear in `block.children`.\n- Insert a new panel via the Slash Menu (search "panel").\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)',
+ },
{
projectSlug: "math-block",
fullSlug: "custom-schema/math-block",
@@ -1566,6 +1593,33 @@ export const examples = {
readme:
'In this example, we build custom blocks on the source-with-preview pattern — the same building blocks behind BlockNote\'s math and diagram blocks. A custom "CSV table" block renders its comma-separated source as a table, and a custom "color" inline content renders a CSS color as a swatch. Both show the rendered preview in place, while the source is edited in a popup.\n\n**Try it out:** Click the table or a color chip to edit its source!\n\n**Relevant Docs:**\n\n- [Source with Preview Blocks](/docs/features/custom-schemas/source-with-preview)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Custom Inline Content](/docs/features/custom-schemas/custom-inline-content)',
},
+ {
+ projectSlug: "callout-block",
+ fullSlug: "custom-schema/callout-block",
+ pathFromRoot: "examples/06-custom-schema/13-callout-block",
+ config: {
+ playground: true,
+ docs: true,
+ author: "nickthesick",
+ tags: [
+ "Intermediate",
+ "Blocks",
+ "Custom Schemas",
+ "Suggestion Menus",
+ "Slash Menu",
+ ],
+ dependencies: {
+ "react-icons": "^5.5.0",
+ } as any,
+ },
+ title: "Callout Block",
+ group: {
+ pathFromRoot: "examples/06-custom-schema",
+ slug: "custom-schema",
+ },
+ readme:
+ 'In this example, we create a custom `Callout` block with a real rich-text title and a body of child blocks (a titled block), like a Notion-style callout.\n\nThe block combines `content: "inline"` with the `children` config on `BlockConfig`. The title is ordinary inline content — formatting, links, and multiplayer cursors all work — while `children: { allow: "blocks" }` hosts the body blocks, which live on `block.children` at runtime. `render` draws the title row and `renderFrame` draws the box around the title and body together.\n\nWe 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 titled block and its nested children.\n\n**Try it out:**\n\n- Press Enter at the end of the callout\'s title to jump into its body.\n- Press Backspace at the start of the first body block to merge it back into the title.\n- Press "/" inside the body and add a code block, heading, or list.\n- Watch the JSON panel on the right update as you edit; the title is `content` and the body is `block.children`.\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)',
+ },
{
projectSlug: "draggable-inline-content",
fullSlug: "custom-schema/draggable-inline-content",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0a4aeb2f0e..03bd5b9292 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3511,6 +3511,52 @@ importers:
specifier: ^8.0.0
version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
+ examples/06-custom-schema/09-container-block:
+ dependencies:
+ '@blocknote/ariakit':
+ specifier: latest
+ version: link:../../../packages/ariakit
+ '@blocknote/core':
+ specifier: latest
+ version: link:../../../packages/core
+ '@blocknote/mantine':
+ specifier: latest
+ version: link:../../../packages/mantine
+ '@blocknote/react':
+ specifier: latest
+ version: link:../../../packages/react
+ '@blocknote/shadcn':
+ specifier: latest
+ version: link:../../../packages/shadcn
+ '@mantine/core':
+ specifier: ^9.0.2
+ version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@mantine/hooks':
+ specifier: ^9.0.2
+ version: 9.1.1(react@19.2.5)
+ react:
+ specifier: ^19.2.3
+ version: 19.2.5
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.5(react@19.2.5)
+ react-icons:
+ specifier: ^5.5.0
+ version: 5.6.0(react@19.2.5)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.3
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
+ vite:
+ specifier: ^8.0.0
+ version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
+
examples/06-custom-schema/09-math-block:
dependencies:
'@blocknote/ariakit':
@@ -3661,6 +3707,52 @@ importers:
specifier: ^8.0.0
version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
+ examples/06-custom-schema/13-callout-block:
+ dependencies:
+ '@blocknote/ariakit':
+ specifier: latest
+ version: link:../../../packages/ariakit
+ '@blocknote/core':
+ specifier: latest
+ version: link:../../../packages/core
+ '@blocknote/mantine':
+ specifier: latest
+ version: link:../../../packages/mantine
+ '@blocknote/react':
+ specifier: latest
+ version: link:../../../packages/react
+ '@blocknote/shadcn':
+ specifier: latest
+ version: link:../../../packages/shadcn
+ '@mantine/core':
+ specifier: ^9.0.2
+ version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@mantine/hooks':
+ specifier: ^9.0.2
+ version: 9.1.1(react@19.2.5)
+ react:
+ specifier: ^19.2.3
+ version: 19.2.5
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.5(react@19.2.5)
+ react-icons:
+ specifier: ^5.5.0
+ version: 5.6.0(react@19.2.5)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.3
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
+ vite:
+ specifier: ^8.0.0
+ version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
+
examples/06-custom-schema/draggable-inline-content:
dependencies:
'@blocknote/ariakit':
diff --git a/tests/src/end-to-end/exporters/exporterTestUtil.tsx b/tests/src/end-to-end/exporters/exporterTestUtil.tsx
index bd857d6ef9..a8e740b163 100644
--- a/tests/src/end-to-end/exporters/exporterTestUtil.tsx
+++ b/tests/src/end-to-end/exporters/exporterTestUtil.tsx
@@ -1,4 +1,5 @@
import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core";
+import { ColumnBlock, ColumnListBlock } from "@blocknote/xl-multi-column";
import { expect } from "vite-plus/test";
import { screenshotFull } from "../../utils/screenshotFull.js";
@@ -38,8 +39,19 @@ export const invalidMathBlock = {
children: [],
} as any;
+// Includes the multi-column blocks, which the shared test document contains.
+// They have to be in the *schema*, not just the mappings: the exporters read
+// the schema to tell a container block from a regular one, and a container
+// the exporter doesn't recognize gets its children appended after it instead
+// of placed by its mapping.
export function schema() {
- return BlockNoteSchema.create({ blockSpecs: defaultBlockSpecs });
+ return BlockNoteSchema.create({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ column: ColumnBlock,
+ columnList: ColumnListBlock,
+ },
+ });
}
/**
diff --git a/tests/src/end-to-end/keyboardhandlers/__snapshots__/enterPreservesNestedBlocks.json b/tests/src/end-to-end/keyboardhandlers/__snapshots__/enterPreservesNestedBlocks.json
index fe2cca639a..c849beeff2 100644
--- a/tests/src/end-to-end/keyboardhandlers/__snapshots__/enterPreservesNestedBlocks.json
+++ b/tests/src/end-to-end/keyboardhandlers/__snapshots__/enterPreservesNestedBlocks.json
@@ -25,28 +25,6 @@
"text": "H"
}
]
- }
- ]
- },
- {
- "type": "blockContainer",
- "attrs": {
- "id": "3"
- },
- "content": [
- {
- "type": "paragraph",
- "attrs": {
- "backgroundColor": "default",
- "textColor": "default",
- "textAlignment": "left"
- },
- "content": [
- {
- "type": "text",
- "text": "eading"
- }
- ]
},
{
"type": "blockGroup",
@@ -102,6 +80,28 @@
]
}
]
+ },
+ {
+ "type": "blockContainer",
+ "attrs": {
+ "id": "3"
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "backgroundColor": "default",
+ "textColor": "default",
+ "textAlignment": "left"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "eading"
+ }
+ ]
+ }
+ ]
}
]
}
diff --git a/tests/src/end-to-end/multicolumn/multicolumn.test.tsx b/tests/src/end-to-end/multicolumn/multicolumn.test.tsx
index 77cd1cd21d..d0c44f19b2 100644
--- a/tests/src/end-to-end/multicolumn/multicolumn.test.tsx
+++ b/tests/src/end-to-end/multicolumn/multicolumn.test.tsx
@@ -11,6 +11,7 @@ import {
import {
compareDocToSnapshot,
focusOnEditor,
+ sleep,
waitForSelector,
} from "../../utils/editor.js";
import {
@@ -134,3 +135,49 @@ describe("Check Multi-Column Behaviour", () => {
await compareDocToSnapshot("deleteEndOfColumnList");
});
});
+
+// Which block the side menu attaches to is resolved from live layout
+// (`elementsFromPoint` / `posAtCoords`); the geometry pieces below that are
+// unit-tested in `packages/core/src/extensions/SideMenu/
+// sideMenuContainerGeometry.browser.test.ts`. This tests the whole path,
+// through a real column list. Columns have no drag handles themselves, but
+// hovering their gutter must still resolve the child on the hovered row.
+describe("Check side menu placement inside a column list", () => {
+ /** Vertical centre of a rect, which the menu lines itself up with. */
+ const centerY = (rect: DOMRect) => rect.y + rect.height / 2;
+
+ test("Check drag handle resolves the block on the hovered row of a column", async () => {
+ await focusOnEditor();
+
+ // The last column is the only one holding several blocks, so it's the only
+ // place a wrongly resolved block is distinguishable by its row.
+ const target = page.getByText("Block 2").element();
+ const columnRect = getRect(target.closest(".bn-block-column")!);
+
+ await mouseSequence([
+ {
+ type: "move",
+ x: columnRect.x + 5,
+ y: centerY(getRect(target)),
+ steps: 5,
+ },
+ ]);
+ await waitForSelector(DRAG_HANDLE_SELECTOR);
+ await sleep(150);
+ const handleRect = getRect(DRAG_HANDLE_SELECTOR);
+
+ expect(handleRect.x).toBeLessThan(getRect(target).x);
+
+ // The handle lines up with the hovered block's row rather than any other
+ // block's. This is a stronger check than a pixel tolerance, since every
+ // candidate is only a line-height away, and it is what distinguishes
+ // this column's blocks from the neighbouring column's.
+ const distance = (rect: DOMRect) =>
+ Math.abs(centerY(handleRect) - centerY(rect));
+ for (const other of ["Block 1", "Block 3", "So is this heading!"]) {
+ expect(distance(getRect(target))).toBeLessThan(
+ distance(getRect(page.getByText(other).element())),
+ );
+ }
+ });
+});
diff --git a/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/containerChildToSiblingAfter.html b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/containerChildToSiblingAfter.html
new file mode 100644
index 0000000000..d6a266cb63
--- /dev/null
+++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/containerChildToSiblingAfter.html
@@ -0,0 +1,7 @@
+
+
+ Callout child 2
+
+
+
+After callout
\ No newline at end of file
diff --git a/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/containerChildren.html b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/containerChildren.html
new file mode 100644
index 0000000000..e9ef47b051
--- /dev/null
+++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/containerChildren.html
@@ -0,0 +1,7 @@
+
+
+ Callout child 1
+ Callout child 2
+
+
+
\ No newline at end of file
diff --git a/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/containerNestedChild.html b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/containerNestedChild.html
new file mode 100644
index 0000000000..65339aa967
--- /dev/null
+++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/containerNestedChild.html
@@ -0,0 +1 @@
+Inner child
\ No newline at end of file
diff --git a/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerChildToSiblingAfter.md b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerChildToSiblingAfter.md
new file mode 100644
index 0000000000..94e29d44e1
--- /dev/null
+++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerChildToSiblingAfter.md
@@ -0,0 +1,3 @@
+Callout child 2
+
+UI LABELAfter callout
diff --git a/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerChildren.md b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerChildren.md
new file mode 100644
index 0000000000..8d71d6bc10
--- /dev/null
+++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerChildren.md
@@ -0,0 +1,5 @@
+Callout child 1
+
+Callout child 2
+
+UI LABEL
diff --git a/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerNestedChild.md b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerNestedChild.md
new file mode 100644
index 0000000000..72d309cf51
--- /dev/null
+++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerNestedChild.md
@@ -0,0 +1 @@
+Inner child
diff --git a/tests/src/unit/core/clipboard/copy/copyTestInstances.ts b/tests/src/unit/core/clipboard/copy/copyTestInstances.ts
index 4bd34489c0..996d90b2c4 100644
--- a/tests/src/unit/core/clipboard/copy/copyTestInstances.ts
+++ b/tests/src/unit/core/clipboard/copy/copyTestInstances.ts
@@ -722,6 +722,80 @@ export const copyTestInstancesHTML: TestInstance<
},
executeTest: testCopyHTML,
},
+ {
+ // The whole of a container's children, selected from inside it.
+ testCase: {
+ name: "containerChildren",
+ document: [
+ {
+ type: "callout",
+ children: [
+ { type: "paragraph", content: "Callout child 1" },
+ { type: "paragraph", content: "Callout child 2" },
+ ],
+ },
+ ],
+ getCopySelection: (doc) => {
+ const startPos = getPosOfTextNode(doc, "Callout child 1");
+ const endPos = getPosOfTextNode(doc, "Callout child 2", true);
+
+ return TextSelection.create(doc, startPos, endPos);
+ },
+ },
+ executeTest: testCopyHTML,
+ },
+ {
+ // A selection that leaves the container partway through, so the copied
+ // fragment is cut open on one side.
+ testCase: {
+ name: "containerChildToSiblingAfter",
+ document: [
+ {
+ type: "callout",
+ children: [
+ { type: "paragraph", content: "Callout child 1" },
+ { type: "paragraph", content: "Callout child 2" },
+ ],
+ },
+ { type: "paragraph", content: "After callout" },
+ ],
+ getCopySelection: (doc) => {
+ const startPos = getPosOfTextNode(doc, "Callout child 2");
+ const endPos = getPosOfTextNode(doc, "After callout", true);
+
+ return TextSelection.create(doc, startPos, endPos);
+ },
+ },
+ executeTest: testCopyHTML,
+ },
+ {
+ // A single block two containers deep, so the fragment is cut open on both
+ // sides at two different levels.
+ testCase: {
+ name: "containerNestedChild",
+ document: [
+ {
+ type: "callout",
+ props: { flavor: "warning" },
+ children: [
+ { type: "paragraph", content: "Outer child" },
+ {
+ type: "callout",
+ props: { flavor: "info" },
+ children: [{ type: "paragraph", content: "Inner child" }],
+ },
+ ],
+ },
+ ],
+ getCopySelection: (doc) => {
+ const startPos = getPosOfTextNode(doc, "Inner child");
+ const endPos = getPosOfTextNode(doc, "Inner child", true);
+
+ return TextSelection.create(doc, startPos, endPos);
+ },
+ },
+ executeTest: testCopyHTML,
+ },
];
// text/plain payloads — exercises the same selections as above but snapshots
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/basic.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/basic.html
new file mode 100644
index 0000000000..e26ee72a29
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/basic.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ Callout child
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/emptyChildren.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/emptyChildren.html
new file mode 100644
index 0000000000..acc0a7fb0c
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/emptyChildren.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/nested.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/nested.html
new file mode 100644
index 0000000000..a77a72a56b
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/nested.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+ Nested heading
+
+
+
+
+
+
+
+
+ Inner callout child
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/titledBlock/basic.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/titledBlock/basic.html
new file mode 100644
index 0000000000..4027ff9eee
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/titledBlock/basic.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+ Heads up
+
+
+
+
+
+ First
+
+
+
+
+
+
+ Second
+
+
+
+
+
+
+
+
+
+
+
+ Title
+
+
+
+
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/container/basic.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/container/basic.html
new file mode 100644
index 0000000000..ede3d1ad38
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/container/basic.html
@@ -0,0 +1,6 @@
+
+
+ Callout child
+
+
+
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/container/emptyChildren.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/container/emptyChildren.html
new file mode 100644
index 0000000000..54690f4f5d
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/container/emptyChildren.html
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/container/nested.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/container/nested.html
new file mode 100644
index 0000000000..3a3fc433c6
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/container/nested.html
@@ -0,0 +1,17 @@
+
+
+ Nested heading
+
+
+ Inner callout child
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/titledBlock/basic.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/titledBlock/basic.html
new file mode 100644
index 0000000000..fbb4149097
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/titledBlock/basic.html
@@ -0,0 +1,8 @@
+
+
+ Heads up
+ First
+ Second
+
+
+Title
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/basic.md b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/basic.md
new file mode 100644
index 0000000000..6ce86c1ffa
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/basic.md
@@ -0,0 +1,3 @@
+Callout child
+
+UI LABEL
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/emptyChildren.md b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/emptyChildren.md
new file mode 100644
index 0000000000..79f979cb57
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/emptyChildren.md
@@ -0,0 +1 @@
+UI LABEL
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/nested.md b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/nested.md
new file mode 100644
index 0000000000..f2a5876e69
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/nested.md
@@ -0,0 +1,5 @@
+# Nested heading
+
+Inner callout child
+
+UI LABELUI LABEL
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/titledBlock/basic.md b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/titledBlock/basic.md
new file mode 100644
index 0000000000..ff8d5b841b
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/titledBlock/basic.md
@@ -0,0 +1,5 @@
+Heads upFirst
+
+Second
+
+Title
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/container/basic.json b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/container/basic.json
new file mode 100644
index 0000000000..cd4b7fa368
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/container/basic.json
@@ -0,0 +1,33 @@
+[
+ {
+ "attrs": {
+ "flavor": "tip",
+ "id": "1",
+ },
+ "content": [
+ {
+ "attrs": {
+ "id": "2",
+ },
+ "content": [
+ {
+ "attrs": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "content": [
+ {
+ "text": "Callout child",
+ "type": "text",
+ },
+ ],
+ "type": "paragraph",
+ },
+ ],
+ "type": "blockContainer",
+ },
+ ],
+ "type": "callout",
+ },
+]
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/container/emptyChildren.json b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/container/emptyChildren.json
new file mode 100644
index 0000000000..7b02725a5d
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/container/emptyChildren.json
@@ -0,0 +1,27 @@
+[
+ {
+ "attrs": {
+ "flavor": "tip",
+ "id": "1",
+ },
+ "content": [
+ {
+ "attrs": {
+ "id": "1",
+ },
+ "content": [
+ {
+ "attrs": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "type": "blockContainer",
+ },
+ ],
+ "type": "callout",
+ },
+]
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/container/nested.json b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/container/nested.json
new file mode 100644
index 0000000000..4a45ff2fa9
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/container/nested.json
@@ -0,0 +1,66 @@
+[
+ {
+ "attrs": {
+ "flavor": "warning",
+ "id": "1",
+ },
+ "content": [
+ {
+ "attrs": {
+ "id": "2",
+ },
+ "content": [
+ {
+ "attrs": {
+ "backgroundColor": "default",
+ "isToggleable": false,
+ "level": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "content": [
+ {
+ "text": "Nested heading",
+ "type": "text",
+ },
+ ],
+ "type": "heading",
+ },
+ ],
+ "type": "blockContainer",
+ },
+ {
+ "attrs": {
+ "flavor": "info",
+ "id": "3",
+ },
+ "content": [
+ {
+ "attrs": {
+ "id": "4",
+ },
+ "content": [
+ {
+ "attrs": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "content": [
+ {
+ "text": "Inner callout child",
+ "type": "text",
+ },
+ ],
+ "type": "paragraph",
+ },
+ ],
+ "type": "blockContainer",
+ },
+ ],
+ "type": "callout",
+ },
+ ],
+ "type": "callout",
+ },
+]
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/titledBlock/basic.json b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/titledBlock/basic.json
new file mode 100644
index 0000000000..44e5e45d26
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/titledBlock/basic.json
@@ -0,0 +1,85 @@
+[
+ {
+ "attrs": {
+ "id": "1",
+ },
+ "content": [
+ {
+ "content": [
+ {
+ "text": "Heads up",
+ "type": "text",
+ },
+ ],
+ "type": "alert",
+ },
+ {
+ "content": [
+ {
+ "attrs": {
+ "id": "2",
+ },
+ "content": [
+ {
+ "attrs": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "content": [
+ {
+ "text": "First",
+ "type": "text",
+ },
+ ],
+ "type": "paragraph",
+ },
+ ],
+ "type": "blockContainer",
+ },
+ {
+ "attrs": {
+ "id": "3",
+ },
+ "content": [
+ {
+ "attrs": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "content": [
+ {
+ "text": "Second",
+ "type": "text",
+ },
+ ],
+ "type": "paragraph",
+ },
+ ],
+ "type": "blockContainer",
+ },
+ ],
+ "type": "blockGroup",
+ },
+ ],
+ "type": "blockContainer",
+ },
+ {
+ "attrs": {
+ "id": "4",
+ },
+ "content": [
+ {
+ "content": [
+ {
+ "text": "Title",
+ "type": "text",
+ },
+ ],
+ "type": "alert",
+ },
+ ],
+ "type": "blockContainer",
+ },
+]
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/export/exportTestInstances.ts b/tests/src/unit/core/formatConversion/export/exportTestInstances.ts
index 1d901c81a9..84c1886fc6 100644
--- a/tests/src/unit/core/formatConversion/export/exportTestInstances.ts
+++ b/tests/src/unit/core/formatConversion/export/exportTestInstances.ts
@@ -3107,6 +3107,91 @@ export const exportTestInstancesBlockNoteHTML: TestInstance<
},
executeTest: testExportBlockNoteHTML,
},
+ {
+ testCase: {
+ name: "container/basic",
+ content: [
+ {
+ type: "callout",
+ children: [
+ {
+ type: "paragraph",
+ content: "Callout child",
+ },
+ ],
+ },
+ ],
+ },
+ executeTest: testExportBlockNoteHTML,
+ },
+ {
+ testCase: {
+ name: "container/nested",
+ content: [
+ {
+ type: "callout",
+ props: { flavor: "warning" },
+ children: [
+ {
+ type: "heading",
+ content: "Nested heading",
+ },
+ {
+ type: "callout",
+ props: { flavor: "info" },
+ children: [
+ {
+ type: "paragraph",
+ content: "Inner callout child",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ executeTest: testExportBlockNoteHTML,
+ },
+ {
+ // Partial input omits the required child. Export snapshots record this
+ // raw form; full-HTML equality tests normalize it to a valid block first.
+ testCase: {
+ name: "container/emptyChildren",
+ content: [
+ {
+ type: "callout",
+ },
+ ],
+ },
+ executeTest: testExportBlockNoteHTML,
+ },
+ {
+ // A titled block: inline content (the title) plus a body of two
+ // paragraphs. The title is rendered once, by the block's own output; the
+ // children follow it inside the frame rather than being duplicated.
+ testCase: {
+ name: "titledBlock/basic",
+ content: [
+ {
+ type: "alert",
+ content: "Heads up",
+ children: [
+ {
+ type: "paragraph",
+ content: "First",
+ },
+ {
+ type: "paragraph",
+ content: "Second",
+ },
+ ],
+ },
+ // Omitted children still reach the frame callback as an empty array.
+ { type: "alert", content: "Title" },
+ ],
+ },
+ executeTest: testExportBlockNoteHTML,
+ },
];
export const exportTestInstancesHTML: TestInstance<
diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/container.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/container.json
new file mode 100644
index 0000000000..9c1e864bd8
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/container.json
@@ -0,0 +1,29 @@
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Callout child",
+ "type": "text",
+ },
+ ],
+ "id": "2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "1",
+ "props": {
+ "flavor": "tip",
+ },
+ "type": "callout",
+ },
+]
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/containerEmptyChildren.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/containerEmptyChildren.json
new file mode 100644
index 0000000000..3333d9ac16
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/containerEmptyChildren.json
@@ -0,0 +1,23 @@
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "1",
+ "props": {
+ "flavor": "tip",
+ },
+ "type": "callout",
+ },
+]
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/containerExternalHTML.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/containerExternalHTML.json
new file mode 100644
index 0000000000..7d0dc770cb
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/containerExternalHTML.json
@@ -0,0 +1,58 @@
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested heading",
+ "type": "text",
+ },
+ ],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "isToggleable": false,
+ "level": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Inner callout child",
+ "type": "text",
+ },
+ ],
+ "id": "2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "3",
+ "props": {
+ "flavor": "info",
+ },
+ "type": "callout",
+ },
+ ],
+ "content": undefined,
+ "id": "1",
+ "props": {
+ "flavor": "warning",
+ },
+ "type": "callout",
+ },
+]
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/containerNested.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/containerNested.json
new file mode 100644
index 0000000000..f26c42c127
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/containerNested.json
@@ -0,0 +1,58 @@
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested heading",
+ "type": "text",
+ },
+ ],
+ "id": "2",
+ "props": {
+ "backgroundColor": "default",
+ "isToggleable": false,
+ "level": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Inner callout child",
+ "type": "text",
+ },
+ ],
+ "id": "4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "3",
+ "props": {
+ "flavor": "info",
+ },
+ "type": "callout",
+ },
+ ],
+ "content": undefined,
+ "id": "1",
+ "props": {
+ "flavor": "warning",
+ },
+ "type": "callout",
+ },
+]
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/titledBlock.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/titledBlock.json
new file mode 100644
index 0000000000..756e05dfa7
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/titledBlock.json
@@ -0,0 +1,50 @@
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "First",
+ "type": "text",
+ },
+ ],
+ "id": "2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Second",
+ "type": "text",
+ },
+ ],
+ "id": "3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Heads up",
+ "type": "text",
+ },
+ ],
+ "id": "1",
+ "props": {},
+ "type": "alert",
+ },
+]
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts b/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts
index 536a7ac784..782b0ebab3 100644
--- a/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts
+++ b/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts
@@ -1119,6 +1119,125 @@ l'utilisateur (bouton bleu en haut à droite de la conversation)
+
+
+
+
+
+ Callout child
+
+
+
+
+
+`,
+ },
+ 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,
+ },
+ {
+ // An empty container children holder is filled to its schema minimum
+ // when parsed into a valid document.
+ testCase: {
+ name: "containerEmptyChildren",
+ content: `
+
+
+
+`,
+ },
+ executeTest: testParseHTML,
+ },
+ {
+ // Container HTML without the `data-children-of` marker our own
+ // serializers add: what a container looks like once it has been through
+ // another app, or has come from an older version. The parse rule has to
+ // fall back to reading the whole element as the children region.
+ testCase: {
+ name: "containerExternalHTML",
+ content: `
+
+ Nested heading
+
+
+ Inner callout child
+
+
+
+`,
+ },
+ executeTest: testParseHTML,
+ },
+ {
+ // The internal (`blocksToFullHTML`) form of a titled block: the title in
+ // the block's own content, the body in its nested `blockGroup`. Parses
+ // back to the alert with its title and both children, in order.
+ testCase: {
+ name: "titledBlock",
+ content: `
+
+
+
+ Heads up
+
+
+
+
+
+ First
+
+
+
+
+
+
+ Second
+
+
+
+
+
+
+`,
+ },
+ 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..99261ea107 100644
--- a/tests/src/unit/core/schema/__snapshots__/blocks.json
+++ b/tests/src/unit/core/schema/__snapshots__/blocks.json
@@ -1,4 +1,21 @@
{
+ "alert": {
+ "config": {
+ "children": {
+ "allow": "blocks",
+ },
+ "content": "inline",
+ "propSchema": {},
+ "type": "alert",
+ },
+ "extensions": undefined,
+ "implementation": {
+ "node": null,
+ "render": [Function],
+ "renderFrame": [Function],
+ "toExternalHTML": [Function],
+ },
+ },
"audio": {
"config": {
"content": "none",
@@ -73,6 +90,31 @@
"toExternalHTML": [Function],
},
},
+ "callout": {
+ "config": {
+ "children": {
+ "allow": "blocks",
+ },
+ "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..c1ddf7b309 100644
--- a/tests/src/unit/core/testSchema.ts
+++ b/tests/src/unit/core/testSchema.ts
@@ -27,7 +27,7 @@ const SimpleImage = createBlockSpec(
),
{
render(block, editor) {
- return createImageBlockSpec().implementation.render.call(
+ return createImageBlockSpec().implementation.render!.call(
this,
block as any,
editor as any,
@@ -99,6 +99,96 @@ 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: "blocks",
+ },
+ },
+ {
+ render() {
+ const callout = document.createElement("div");
+ callout.className = "callout";
+ // The serializer must overwrite author attributes with the block props.
+ callout.setAttribute("data-flavor", "author-value");
+
+ const body = document.createElement("div");
+ body.className = "callout-body";
+ callout.appendChild(body);
+
+ if (this.renderType === "dom") {
+ // Exercise fragment roots and chrome outside the children region in
+ // the shared HTML snapshots and full-HTML equality matrix.
+ const chrome = document.createElement("button");
+ chrome.contentEditable = "false";
+ chrome.textContent = "UI LABEL";
+ callout.append(chrome);
+ const fragment = document.createDocumentFragment();
+ fragment.append(callout);
+ return { dom: fragment, contentDOM: body };
+ }
+
+ return {
+ dom: callout,
+ contentDOM: body,
+ };
+ },
+ },
+);
+
+// A titled block: an ordinary block with inline content (the title) whose
+// `children` are a body that belongs to it. Covers titled blocks in the
+// format-conversion, clipboard and selection matrices, which otherwise never
+// see one (the `callout` above only covers pure containers).
+const Alert = createBlockSpec(
+ {
+ type: "alert" as const,
+ propSchema: {},
+ content: "inline",
+ children: {
+ allow: "blocks",
+ },
+ },
+ {
+ render: () => {
+ const alert = document.createElement("div");
+ alert.className = "alert";
+
+ return {
+ dom: alert,
+ contentDOM: alert,
+ };
+ },
+ renderFrame: (block) => {
+ if (block.children.length === 0) {
+ return undefined;
+ }
+ const frame = document.createElement("div");
+ frame.className = "alert-frame";
+
+ const slot = document.createElement("div");
+ slot.className = "alert-slot";
+ frame.appendChild(slot);
+
+ return {
+ dom: frame,
+ slot,
+ };
+ },
+ },
+);
+
// INLINE CONTENT --------------------------------------------------------------
const Mention = createInlineContentSpec(
@@ -222,6 +312,8 @@ export const testSchema = BlockNoteSchema.create().extend({
customParagraph: CustomParagraph(),
simpleCustomParagraph: SimpleCustomParagraph(),
simpleImage: SimpleImage(),
+ callout: Callout(),
+ alert: Alert(),
},
inlineContentSpecs: {
mention: Mention,
diff --git a/tests/src/unit/react/reactFrame.test.tsx b/tests/src/unit/react/reactFrame.test.tsx
new file mode 100644
index 0000000000..78c93edee4
--- /dev/null
+++ b/tests/src/unit/react/reactFrame.test.tsx
@@ -0,0 +1,382 @@
+import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core";
+import {
+ BlockNoteViewRaw,
+ createReactBlockSpec,
+ type ReactCustomBlockFrameProps,
+} from "@blocknote/react";
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ type ReactNode,
+} from "react";
+import { flushSync } from "react-dom";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vite-plus/test";
+
+// Pure containers continue to draw their outer box through render.
+const createFrameBox = createReactBlockSpec(
+ {
+ type: "frameBox",
+ propSchema: { flavor: { default: "tip" } },
+ content: "none",
+ children: { allow: "blocks" },
+ },
+ {
+ render: function Container(props) {
+ const [alternate, setAlternate] = useState(false);
+ const Tag = alternate ? "section" : "div";
+ return (
+
+
+
+
+ );
+ },
+ },
+);
+
+const FrameContext = createContext("outside");
+let activeFrames = 0;
+const alertConfig = {
+ type: "frameAlert",
+ propSchema: { flavor: { default: "tip" }, framed: { default: true } },
+ content: "inline",
+ children: { allow: "blocks" },
+} as const;
+
+function FrameChrome(props: { children: ReactNode; flavor: string }) {
+ return (
+
+ {props.children}
+
+ );
+}
+
+function AlertFrame(props: ReactCustomBlockFrameProps) {
+ const label = useContext(FrameContext);
+ const [clicks, setClicks] = useState(0);
+ useEffect(() => {
+ activeFrames++;
+ return () => {
+ activeFrames--;
+ };
+ }, []);
+ if (!props.block.props.framed) {
+ return null;
+ }
+ return (
+
+
+
+
+
+ );
+}
+
+const createFrameAlert = createReactBlockSpec(alertConfig, {
+ render: (props) => ,
+ renderFrame: AlertFrame,
+});
+
+// Framing alone must not turn ordinary nesting into an owned body.
+const createToggle = createReactBlockSpec(
+ { type: "frameToggle", propSchema: {}, content: "inline" },
+ {
+ render: (props) => ,
+ renderFrame: (props) => (
+
+ ),
+ },
+);
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ frameBox: createFrameBox(),
+ frameAlert: createFrameAlert(),
+ frameToggle: createToggle(),
+ },
+});
+
+let root: Root | undefined;
+let div: HTMLDivElement | undefined;
+let editor: BlockNoteEditor | undefined;
+/** Render-phase errors. React 19 reports rather than rethrows these. */
+let uncaught: unknown[] = [];
+
+const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+function mountEditor(initialContent: any[]) {
+ div = document.createElement("div");
+ document.body.appendChild(div);
+
+ editor = BlockNoteEditor.create({
+ schema,
+ trailingBlock: false,
+ initialContent,
+ }) as BlockNoteEditor;
+
+ uncaught = [];
+ root = createRoot(div, {
+ onUncaughtError: (error) => uncaught.push(error),
+ onCaughtError: (error) => uncaught.push(error),
+ });
+ // Mount during a React commit as applications do. Frame construction must
+ // not depend on a nested synchronous React render succeeding.
+ flushSync(() => {
+ root!.render(
+
+
+ ,
+ );
+ });
+
+ return editor;
+}
+
+afterEach(() => {
+ root?.unmount();
+ root = undefined;
+ if (div) {
+ document.body.removeChild(div);
+ div = undefined;
+ }
+ editor?._tiptapEditor.destroy();
+ editor = undefined;
+ expect(activeFrames).toBe(0);
+});
+
+describe("React renderFrame", () => {
+ it("keeps container identity and child DOM through prop and author-root changes", async () => {
+ const mounted = mountEditor([
+ {
+ id: "box-0",
+ type: "frameBox",
+ props: { flavor: "warning" },
+ children: [{ id: "box-child", type: "paragraph", content: "Child" }],
+ },
+ ]);
+ await tick();
+ const child = div!.querySelector('[data-id="box-child"]');
+ const slot = div!.querySelector(".frame-slot");
+ expect(slot?.textContent).toBe("Child");
+ mounted.updateBlock("box-0", { props: { flavor: "success" } });
+ await vi.waitFor(() =>
+ expect(
+ div!.querySelector(".frame-box")?.getAttribute("data-flavor"),
+ ).toBe("success"),
+ );
+ expect(div!.querySelector(".frame-slot")).toBe(slot);
+ flushSync(() =>
+ div!.querySelector(".swap-root")!.click(),
+ );
+ const box = div!.querySelector("section.frame-box")!;
+ expect(box).not.toBeNull();
+ expect(box.getAttribute("data-id")).toBe("box-0");
+ expect(box.getAttribute("data-node-type")).toBe("frameBox");
+ expect(box.getAttribute("data-flavor")).toBe("success");
+ expect(box.querySelector('[data-id="box-child"]')).toBe(child);
+ expect(mounted.getBlock("box-child")?.content).toEqual([
+ { type: "text", text: "Child", styles: {} },
+ ]);
+ expect(uncaught).toEqual([]);
+ });
+
+ it("renders plain nesting when the frame mounts no slot", async () => {
+ mountEditor([
+ {
+ id: "alert-0",
+ type: "frameAlert",
+ props: { framed: false },
+ content: "Heads up",
+ children: [{ id: "alert-child", type: "paragraph", content: "Body" }],
+ },
+ ]);
+ await tick();
+
+ expect(div!.querySelector(".alert-frame")).toBeNull();
+ expect(div!.textContent).toContain("Heads up");
+ expect(div!.textContent).toContain("Body");
+ expect(uncaught).toEqual([]);
+ });
+
+ it("keeps React state, context, and child DOM through frame updates", async () => {
+ const mounted = mountEditor([
+ {
+ id: "alert",
+ type: "frameAlert",
+ content: "Title",
+ children: [{ id: "child", type: "paragraph", content: "Body" }],
+ },
+ ]);
+ await vi.waitFor(() =>
+ expect(div!.querySelector(".frame-counter")?.textContent).toBe(
+ "inside: 0",
+ ),
+ );
+ expect(div!.querySelector(".alert-slot")?.textContent).toBe("TitleBody");
+ const child = div!.querySelector('[data-id="child"]');
+ const title = div!.querySelector(".alert-title");
+ div!.querySelector(".frame-counter")!.click();
+ await vi.waitFor(() =>
+ expect(div!.querySelector(".frame-counter")?.textContent).toBe(
+ "inside: 1",
+ ),
+ );
+ div!.querySelector(".frame-flavor")!.click();
+ await vi.waitFor(() =>
+ expect(
+ div!.querySelector(".alert-frame")?.getAttribute("data-flavor"),
+ ).toBe("warning"),
+ );
+ root!.render(
+
+
+ ,
+ );
+ await vi.waitFor(() =>
+ expect(div!.querySelector(".frame-counter")?.textContent).toBe(
+ "updated: 1",
+ ),
+ );
+ expect(div!.querySelector('[data-id="child"]')).toBe(child);
+ expect(div!.querySelector(".alert-title")).toBe(title);
+ expect(mounted.getBlock("child")?.content).toEqual([
+ { type: "text", text: "Body", styles: {} },
+ ]);
+ expect(uncaught).toEqual([]);
+ });
+
+ it("switches framing off and on without losing title or child node views", async () => {
+ const mounted = mountEditor([
+ {
+ id: "alert",
+ type: "frameAlert",
+ content: "Title",
+ children: [{ id: "child", type: "paragraph", content: "Body" }],
+ },
+ ]);
+ await vi.waitFor(() =>
+ expect(div!.querySelector(".alert-slot")?.textContent).toBe("TitleBody"),
+ );
+ const child = div!.querySelector('[data-id="child"]');
+ mounted.updateBlock("alert", { props: { framed: false } });
+ await vi.waitFor(() =>
+ expect(div!.querySelector(".alert-frame")).toBeNull(),
+ );
+ expect(div!.textContent).toContain("TitleBody");
+ expect(div!.querySelector('[data-id="child"]')).toBe(child);
+ mounted.updateBlock("alert", { props: { framed: true } });
+ await vi.waitFor(() =>
+ expect(div!.querySelector(".alert-slot")?.textContent).toBe("TitleBody"),
+ );
+ expect(div!.querySelector('[data-id="child"]')).toBe(child);
+ expect(uncaught).toEqual([]);
+ });
+
+ it("unmounts the frame component when its block is removed", async () => {
+ const mounted = mountEditor([
+ { id: "alert", type: "frameAlert", content: "Title" },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+ await vi.waitFor(() => expect(activeFrames).toBe(1));
+ mounted.removeBlocks(["alert"]);
+ await vi.waitFor(() => expect(activeFrames).toBe(0));
+ expect(div!.querySelector(".alert-frame")).toBeNull();
+ expect(uncaught).toEqual([]);
+ });
+
+ it("exports a titled React frame with its content and children in the slot", () => {
+ const headless = BlockNoteEditor.create({ schema });
+ try {
+ const html = headless.blocksToHTMLLossy([
+ {
+ type: "frameAlert",
+ content: "Title",
+ children: [{ type: "paragraph", content: "Body" }],
+ },
+ ]);
+ const output = document.createElement("div");
+ output.innerHTML = html;
+ expect(output.querySelector(".alert-slot")?.textContent).toBe(
+ "TitleBody",
+ );
+ expect(output.querySelector(".frame-counter")?.textContent).toBe(
+ "outside: 0",
+ );
+ } finally {
+ headless._tiptapEditor.destroy();
+ }
+ });
+
+ it("exports a declined frame as plain content and disposes its effects", () => {
+ const headless = BlockNoteEditor.create({ schema });
+ try {
+ const html = headless.blocksToHTMLLossy([
+ {
+ type: "frameAlert",
+ props: { framed: false },
+ content: "Title",
+ children: [{ type: "paragraph", content: "Body" }],
+ },
+ ]);
+ expect(html).not.toContain("alert-frame");
+ expect(html).toContain("Title");
+ expect(html).toContain("Body");
+ expect(activeFrames).toBe(0);
+ } finally {
+ headless._tiptapEditor.destroy();
+ }
+ });
+
+ it("keeps ordinary Shift-Tab behavior inside a frame without declared children", async () => {
+ const mounted = mountEditor([
+ {
+ id: "toggle",
+ type: "frameToggle",
+ content: "Title",
+ children: [{ id: "child", type: "paragraph", content: "Body" }],
+ },
+ ]);
+ await vi.waitFor(() =>
+ expect(div!.querySelector(".toggle-frame")?.textContent).toBe(
+ "TitleBody",
+ ),
+ );
+ mounted.setTextCursorPosition("child", "start");
+ const event = new KeyboardEvent("keydown", {
+ key: "Tab",
+ code: "Tab",
+ keyCode: 9,
+ shiftKey: true,
+ bubbles: true,
+ });
+ mounted._tiptapEditor.view.dom.dispatchEvent(event);
+ expect(mounted.getParentBlock("child")).toBeUndefined();
+ expect(mounted.getBlock("toggle")?.children).toHaveLength(0);
+ expect(uncaught).toEqual([]);
+ });
+});
diff --git a/tests/src/unit/react/useNodeViewBlock.test.tsx b/tests/src/unit/react/useNodeViewBlock.test.tsx
index 71101c7fa7..c20c6aaeb9 100644
--- a/tests/src/unit/react/useNodeViewBlock.test.tsx
+++ b/tests/src/unit/react/useNodeViewBlock.test.tsx
@@ -27,8 +27,20 @@ const createReproBlock = createReactBlockSpec(
{ render: (props) => },
);
+// A container block, whose node view's node is itself the bnBlock, resolved
+// directly instead of by position.
+const createBoxBlock = createReactBlockSpec(
+ {
+ type: "box",
+ propSchema: {},
+ content: "none",
+ children: { allow: "blocks" },
+ },
+ { render: (props) => },
+);
+
const schema = BlockNoteSchema.create().extend({
- blockSpecs: { repro: createReproBlock() },
+ blockSpecs: { repro: createReproBlock(), box: createBoxBlock() },
});
let editor: BlockNoteEditor;
@@ -43,6 +55,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 +91,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 +186,14 @@ describe("useNodeViewBlock", () => {
expect(resolved.id).toBe(target.id);
expect(resolved).not.toBe(seed);
});
+
+ it("resolves container blocks directly without consulting their position", () => {
+ const box = editor.document.find((block) => block.type === "box")!;
+ const { node } = getNodeById(box.id, editor.prosemirrorState.doc)!;
+ const getPos = vi.fn(() => undefined);
+ const resolved = renderHook(makeProps(getPos, node), editor.document[0]);
+
+ expect(resolved).toEqual(box);
+ expect(getPos).not.toHaveBeenCalled();
+ });
});
diff --git a/tests/src/unit/shared/formatConversion/exportParseEquality/exportParseEqualityTestExecutors.ts b/tests/src/unit/shared/formatConversion/exportParseEquality/exportParseEqualityTestExecutors.ts
index 9ac5b7df5f..c5d091deae 100644
--- a/tests/src/unit/shared/formatConversion/exportParseEquality/exportParseEqualityTestExecutors.ts
+++ b/tests/src/unit/shared/formatConversion/exportParseEquality/exportParseEqualityTestExecutors.ts
@@ -26,17 +26,24 @@ export const testExportParseEqualityBlockNoteHTML = <
addIdsToBlocks(testCase.content);
- const exported = editor.blocksToFullHTML(testCase.content);
-
if (testCase.name.startsWith("malformed/")) {
- // We purposefully are okay with malformed response, we know they won't match
+ const exported = editor.blocksToFullHTML(testCase.content);
+ // Malformed partial input is intentionally not a lossless document.
expect(editor.tryParseHTMLToBlocks(exported)).not.toStrictEqual(
partialBlocksToBlocksForTesting(editor.schema, testCase.content),
);
} else {
- expect(editor.tryParseHTMLToBlocks(exported)).toStrictEqual(
- partialBlocksToBlocksForTesting(editor.schema, testCase.content),
+ // Round-trip valid blocks, including schema-generated required children.
+ // The shorthand helper defaults all children to [], which is invalid for
+ // containers with a minimum child count.
+ const blocks = testCase.content.map((block) =>
+ nodeToBlock(
+ blockToNode(block, editor.pmSchema),
+ editor.prosemirrorState.doc,
+ ),
);
+ const exported = editor.blocksToFullHTML(blocks);
+ expect(editor.tryParseHTMLToBlocks(exported)).toStrictEqual(blocks);
}
};