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..51364c7888
--- /dev/null
+++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx
@@ -0,0 +1,201 @@
+---
+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.
+
+
+
+## Creating a Container Block
+
+Use the `createReactBlockSpec` function to create a container block, just like a [Custom Block](/docs/features/custom-schemas/custom-blocks). For the panel below, we set `content` to `"none"` and `container` to `true`, so the panel's child blocks go inside it:
+
+```tsx
+import { createReactBlockSpec } from "@blocknote/react";
+
+export const createPanel = createReactBlockSpec(
+ {
+ type: "panel",
+ propSchema: {},
+ content: "none",
+ container: true,
+ },
+ {
+ render: (props) => (
+
+ ),
+ },
+);
+```
+
+### Block Config
+
+The block config defines the content and child blocks your container can hold:
+
+`content:` Must be `"none"` for a container: its node holds nothing but its child blocks. For a block with its own text and child blocks, see [Combining Content and Child Blocks](#combining-content-and-child-blocks).
+
+`container:` Set to `true` to put the block's child blocks inside it. Without it, a block's child blocks are indented below it. A container accepts any block by default. You can also restrict it to specific container types, as explained in [Restricting Children](#restricting-children).
+
+`propSchema:` Defines the container's props, just like for other custom blocks. Use these to customize its appearance or behavior.
+
+### Block Implementation
+
+`render:` Your React component defines how the block should look. For a container, attach `contentRef` where the child blocks should appear. With `content: "inline"` or `"plain"`, attach it to the block's own editable text. You can add icons, buttons, or other elements around it:
+
+```tsx
+render: (props) => (
+
+ 💡
+
+
+),
+```
+
+You can style the component with CSS, just like any other React component:
+
+```css
+.panel {
+ display: flex;
+ gap: 12px;
+ padding: 16px;
+ border-left: 4px solid #507aff;
+ border-radius: 6px;
+}
+```
+
+## Adding Container Blocks to the Editor
+
+Add your container to a [BlockNote schema](/docs/features/custom-schemas#creating-your-own-schema):
+
+```typescript
+import { BlockNoteSchema } from "@blocknote/core";
+import { createPanel } from "./Panel";
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ panel: createPanel(),
+ },
+});
+```
+
+You can then create an editor with this schema, as explained on the [Custom Schemas](/docs/features/custom-schemas) page. Use `children` to set the blocks inside a panel:
+
+```typescript
+import { useCreateBlockNote } from "@blocknote/react";
+
+const editor = useCreateBlockNote({
+ schema,
+ initialContent: [
+ {
+ type: "panel",
+ children: [
+ { type: "heading", content: "Getting started" },
+ { type: "paragraph", content: "Follow these steps to get set up." },
+ { type: "checkListItem", content: "Create an account" },
+ ],
+ },
+ ],
+});
+```
+
+If you create a panel without specifying its children, it starts with an empty paragraph. To let users insert panels themselves, add a [custom Slash Menu item](/docs/react/components/suggestion-menus#changing-slash-menu-items), as shown in the demo.
+
+## Combining Content and Child Blocks
+
+A block can have both its own text and child blocks. Use this for a question followed by hints, a checklist item with detailed instructions, a code sample followed by explanatory blocks, or a callout with a heading. In the demo below, we use the block's text as a callout title:
+
+
+
+Set `content` to `"inline"` for rich text or `"plain"` for unstyled text. Every block with content can have child blocks, so there's nothing to declare for them. Use `render` for the block's own text, `renderFrame` to style that content and its child blocks together, and `keyboard` to keep the child blocks inside the block:
+
+```tsx
+import { createReactBlockSpec } from "@blocknote/react";
+
+export const createCallout = createReactBlockSpec(
+ {
+ type: "callout",
+ propSchema: {},
+ content: "inline",
+ },
+ {
+ experimental_keyboard: {
+ enter: "into-children",
+ childrenCanOutdent: false,
+ emptyChildEnter: "exit-at-end",
+ },
+ render: (props) => (
+
+ ),
+ renderFrame: (props) => (
+
+
+
+ ),
+ },
+);
+```
+
+`render:` Attach `contentRef` to the block's own editable text. With `content: "inline"`, users can format it and add links, just like in a paragraph. In this example, we style it as a callout title.
+
+For plain text, set `content: "plain"` and use a `
` 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.
+
+`experimental_keyboard:` Without it, child blocks behave like any indented blocks. This API is experimental and may change. Here, `enter: "into-children"` makes Enter in the title add a first child block, `childrenCanOutdent: false` keeps Shift-Tab from moving blocks out of the callout, and `emptyChildEnter: "exit-at-end"` makes Enter in an empty last block leave the callout. See [Custom Blocks](/docs/features/custom-schemas/custom-blocks) for all keyboard settings.
+
+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 blocks inside it:
+
+```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 adds a paragraph inside the callout, and Enter in an empty last paragraph leaves the callout. Moving the callout moves its title and child blocks 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:
+container: true,
+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:
+container: true,
+placeable: "namedOnly",
+```
+
+`children.allow:` Accepts `"blocks"` (the default) 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 need `container: true`. Other blocks can always have child blocks of any type, so for them `children` can only be the default, `{ allow: "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..19ae567950 100644
--- a/docs/content/docs/features/custom-schemas/custom-blocks.mdx
+++ b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
@@ -56,6 +56,12 @@ type BlockConfig = {
type: string;
content: "inline" | "plain" | "none";
readonly propSchema: PropSchema;
+ container?: true; // only with content: "none"
+ children?: {
+ allow?: "blocks" | string[];
+ min?: number;
+ };
+ placeable?: "anywhere" | "namedOnly";
};
```
@@ -72,6 +78,14 @@ type BlockConfig = {
alert, so we set `content` to `"inline"`._
+
+ _Every block can have child blocks, indented below it. A block without
+ content can also hold them inside itself with `container: true`. See
+ [Container Blocks](/docs/features/custom-schemas/container-blocks)._
+
+
+`container?:` Puts the block's child blocks inside it. `children?:` Restricts which child blocks a container can hold. `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
@@ -133,8 +147,19 @@ type ReactCustomBlockImplementation = {
schema: Schema;
}) => Fragment | undefined;
runsBefore?: string[];
+ experimental_keyboard?:
+ | KeyboardSettings
+ | ((block: Block) => KeyboardSettings);
+ // KeyboardSettings: {
+ // enter?: "split" | "into-children" | "line-break";
+ // shiftEnter?: "line-break" | "same-as-enter";
+ // splitKeepsType?: boolean;
+ // resetsTo?: { type: string; props?: Record };
+ // emptyEnterResets?: boolean;
+ // emptyChildEnter?: "outdent" | "exit-at-end" | "stay";
+ // childrenCanOutdent?: boolean;
+ // }
meta?: {
- hardBreakShortcut?: "shift+enter" | "enter" | "none";
selectable?: boolean;
fileBlockAccept?: string[];
code?: boolean;
@@ -171,9 +196,25 @@ type ReactCustomBlockImplementation = {
`runsBefore?:` If this block has parsing or extensions that need to be given priority over any other blocks, you can pass their `type`s in an array here.
-`meta?:` An object for setting various generic properties of the block.
+`experimental_keyboard?:` How the keyboard treats the block and its children. This API is experimental and may change. Give only the settings that differ from the defaults. To make settings depend on the block's props, give a function that gets the block and returns them instead.
+
+- `enter?:` What Enter does in the block's content. `"split"` (default) splits the block, moving the text after the caret into a new block below. `"into-children"` moves it into a new first child instead. `"line-break"` inserts a line break, and makes Shift-Enter do the same. For `content: "plain"` blocks (which can't hold hard break nodes), a line break is a literal newline (`"\n"`).
+
+- `shiftEnter?:` What Shift-Enter does in the block's content: `"line-break"` (default) or `"same-as-enter"`.
-- `hardBreakShortcut?:` Defines which keyboard shortcut should be used to insert a hard break into the block's inline content. Defaults to `"shift+enter"`. For `content: "plain"` blocks (which can't hold hard break nodes), the shortcut inserts a literal newline (`"\n"`) instead.
+- `splitKeepsType?:` Whether the block created by splitting this one with Enter has the same type, as in lists. Defaults to `false`.
+
+- `resetsTo?:` What the block turns into when it's reset, keeping its content and children. Backspace at the start of the block always resets it. A `type`, and `props` to merge into the block's props. Defaults to `{ type: "paragraph" }`.
+
+- `emptyEnterResets?:` Whether Enter in the empty block resets it too, as when an empty list item turns into a paragraph. Defaults to `false`.
+
+- `emptyChildEnter?:` What Enter does in an empty child of this block. `"outdent"` (default) outdents any empty child. `"exit-at-end"` (default for container blocks) moves an empty last child out to after the block, and adds a new child after any other empty child. `"stay"` always adds a new child after it.
+
+- `childrenCanOutdent?:` Whether the block's children can be outdented out of it with Shift-Tab, or by Enter or Backspace in an empty or nested child. Defaults to `true`, or `false` for container blocks, whose children can never be outdented.
+
+When settings meet, Enter at the start of non-empty content always inserts an empty block above it, and resetting an empty block comes before `enter: "into-children"`.
+
+`meta?:` An object for setting various generic properties of the block.
- `selectable?:` Can be set to false in order to make the block non-selectable, both using the mouse and keyboard. This also helps with being able to select non-editable content within the block. Should only be set to false when `content` is `none` and defaults to true.
diff --git a/docs/content/docs/features/custom-schemas/source-with-preview.mdx b/docs/content/docs/features/custom-schemas/source-with-preview.mdx
index 03dd18c51e..1acfe19827 100644
--- a/docs/content/docs/features/custom-schemas/source-with-preview.mdx
+++ b/docs/content/docs/features/custom-schemas/source-with-preview.mdx
@@ -62,7 +62,7 @@ return (
A few more props customize the states: `errorPreview` for the compact error state shown in place of the preview, `emptySourcePlaceholder` for when the source is empty (a string customizes the default placeholder's text, an element — e.g. the exported `PreviewPlaceholder` with your own icon — replaces it entirely), and `sourcePlaceholder` for the popup input's placeholder. See the `SourceWithPreviewProps` type for the full list.
-**3. The spec's `meta`**, opting into the popup:
+**3. The spec's `meta`**, opting into the popup, and its `experimental_keyboard`:
```tsx
const createMyBlockSpec = createReactBlockSpec(createMyBlockConfig, {
@@ -70,10 +70,12 @@ const createMyBlockSpec = createReactBlockSpec(createMyBlockConfig, {
code: true,
// Marks the block as rendering a preview with an editable source popup.
hasPreview: true,
- // What Enter does while the popup is open: "enter" inserts a newline
- // (multiline sources, like diagrams), "shift+enter" closes the popup
- // (single-line sources, like math).
- hardBreakShortcut: "enter",
+ },
+ // What Enter does while the popup is open: "line-break" inserts a newline
+ // (multiline sources, like diagrams). Without it, Enter closes the popup
+ // (single-line sources, like math).
+ experimental_keyboard: {
+ enter: "line-break",
},
render: MyBlockPreview,
});
diff --git a/docs/content/docs/features/export/typst.mdx b/docs/content/docs/features/export/typst.mdx
index 4a5e0ddaa3..0485b0839b 100644
--- a/docs/content/docs/features/export/typst.mdx
+++ b/docs/content/docs/features/export/typst.mdx
@@ -120,6 +120,26 @@ 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).
+### Blocks that place their children
+
+By default, a mapping renders only its block, and the exporter places the
+block's children after it, indented. A block whose children are part of it -
+a [container block](/docs/features/custom-schemas/container-blocks), or a
+callout with a body - uses a `{ withChildren }` mapping instead: the exporter renders
+the children first and passes them in as its last argument, and the mapping
+decides where they go. Container blocks must use a `{ withChildren }` mapping, and a
+container without one is an error rather than a silent omission, since
+dropping it would drop its children too.
+
+```typescript
+myContainer: {
+ withChildren: (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 bc05b7596a..af4cc0134b 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/src/App.tsx b/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx
index eeb8e38887..155318652d 100644
--- a/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx
+++ b/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx
@@ -38,7 +38,7 @@ const CustomFormattingToolbar = () => {
// Sets the items in the Block Type Select.
blockTypeSelectItems={[
// Gets the default Block Type Select items.
- ...blockTypeSelectItems(editor.dictionary),
+ ...blockTypeSelectItems(editor),
// Adds an item for the Alert block.
{
name: "Alert",
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/src/MUIFormattingToolbar.tsx b/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx
index 7a1b7d6b6d..b4833393a2 100644
--- a/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx
+++ b/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx
@@ -115,8 +115,8 @@ function MUIBlockTypeSelect() {
// Gets the default items for the select.
const defaultBlockTypeSelectItems = useMemo(
- () => blockTypeSelectItems(editor.dictionary),
- [editor.dictionary],
+ () => blockTypeSelectItems(editor),
+ [editor],
);
// Gets the selected item.
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-mobile-formatting-toolbar/vite.config.ts b/examples/03-ui-components/14-mobile-formatting-toolbar/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/14-mobile-formatting-toolbar/vite.config.ts
+++ b/examples/03-ui-components/14-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/src/App.tsx b/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx
index 931c0e1169..409d7a1e97 100644
--- a/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx
+++ b/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx
@@ -42,7 +42,7 @@ const CustomFormattingToolbar = () => {
// Sets the items in the Block Type Select.
blockTypeSelectItems={[
// Gets the default Block Type Select items.
- ...blockTypeSelectItems(editor.dictionary),
+ ...blockTypeSelectItems(editor),
// Adds an item for the Alert block.
{
name: "Alert",
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/.bnexample.json b/examples/06-custom-schema/06-toggleable-blocks/.bnexample.json
deleted file mode 100644
index 6d4a02dd52..0000000000
--- a/examples/06-custom-schema/06-toggleable-blocks/.bnexample.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "playground": true,
- "docs": true,
- "author": "matthewlipski",
- "tags": ["Basic"]
-}
diff --git a/examples/06-custom-schema/06-toggleable-blocks/README.md b/examples/06-custom-schema/06-toggleable-blocks/README.md
deleted file mode 100644
index 4bbaaa70e1..0000000000
--- a/examples/06-custom-schema/06-toggleable-blocks/README.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# Toggleable Custom Blocks
-
-This example shows how to create custom blocks with a toggle button to show/hide their children, like with the default toggle heading and list item blocks. This is done using the use the `ToggleWrapper` component from `@blocknote/react`.
-
-**Relevant Docs:**
-
-- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)
-- [Editor Setup](/docs/getting-started/editor-setup)
-- [Default Schema](/docs/features/blocks)
diff --git a/examples/06-custom-schema/06-toggleable-blocks/src/App.tsx b/examples/06-custom-schema/06-toggleable-blocks/src/App.tsx
deleted file mode 100644
index 81877de7e4..0000000000
--- a/examples/06-custom-schema/06-toggleable-blocks/src/App.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core";
-import "@blocknote/core/fonts/inter.css";
-import { BlockNoteView } from "@blocknote/mantine";
-import "@blocknote/mantine/style.css";
-import { useCreateBlockNote } from "@blocknote/react";
-
-import { ToggleBlock } from "./Toggle";
-
-// Our schema with block specs, which contain the configs and implementations for
-// blocks that we want our editor to use.
-const schema = BlockNoteSchema.create({
- blockSpecs: {
- // Adds all default blocks.
- ...defaultBlockSpecs,
- // Adds the Toggle block.
- toggle: ToggleBlock(),
- },
-});
-
-export default function App() {
- // Creates a new editor instance.
- const editor = useCreateBlockNote({
- schema,
- initialContent: [
- {
- type: "paragraph",
- content: "Welcome to this demo!",
- },
- {
- // We set a persistent ID so that the toggled state is preserved
- // on reload.
- id: "toggle",
- type: "toggle",
- content: "This is an example toggle",
- children: [
- {
- type: "paragraph",
- content: "This is the first child of the toggle block.",
- },
- {
- type: "paragraph",
- content: "This is the second child of the toggle block.",
- },
- ],
- },
- {
- type: "paragraph",
- content: "Click the '>' icon to show/hide its children",
- },
- ],
- });
-
- // Renders the editor instance.
- return ;
-}
diff --git a/examples/06-custom-schema/06-toggleable-blocks/src/Toggle.tsx b/examples/06-custom-schema/06-toggleable-blocks/src/Toggle.tsx
deleted file mode 100644
index 244661f841..0000000000
--- a/examples/06-custom-schema/06-toggleable-blocks/src/Toggle.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { defaultProps } from "@blocknote/core";
-import { createReactBlockSpec, ToggleWrapper } from "@blocknote/react";
-
-// The Toggle block that we want to add to our editor.
-export const ToggleBlock = createReactBlockSpec(
- {
- type: "toggle",
- propSchema: {
- ...defaultProps,
- },
- content: "inline",
- },
- {
- render: (props) => (
- // The `ToggleWrapper` component renders a button on the left which
- // toggles the visibility of the block's children. It also adds a button
- // to add child blocks if there are none. By default, it uses local
- // storage to remember the toggled state based on the block ID, but you can pass a custom
- // `toggledState` prop to use a different storage mechanism.
-
-
-
- ),
- },
-);
diff --git a/examples/06-custom-schema/06-toggleable-blocks/src/vite-env.d.ts b/examples/06-custom-schema/06-toggleable-blocks/src/vite-env.d.ts
deleted file mode 100644
index bc2d8a36f3..0000000000
--- a/examples/06-custom-schema/06-toggleable-blocks/src/vite-env.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-///
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..5ecd226989
--- /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 sets `container: true` on `BlockConfig`, which 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/06-toggleable-blocks/index.html b/examples/06-custom-schema/09-container-block/index.html
similarity index 89%
rename from examples/06-custom-schema/06-toggleable-blocks/index.html
rename to examples/06-custom-schema/09-container-block/index.html
index bdac14de54..34d12680d0 100644
--- a/examples/06-custom-schema/06-toggleable-blocks/index.html
+++ b/examples/06-custom-schema/09-container-block/index.html
@@ -5,7 +5,7 @@
name="viewport"
content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content"
/>
- Toggleable Custom Blocks
+ Container Block
diff --git a/examples/06-custom-schema/06-toggleable-blocks/main.tsx b/examples/06-custom-schema/09-container-block/main.tsx
similarity index 100%
rename from examples/06-custom-schema/06-toggleable-blocks/main.tsx
rename to examples/06-custom-schema/09-container-block/main.tsx
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..49a623fa31
--- /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",
+ container: true,
+ },
+ {
+ // 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/06-toggleable-blocks/tsconfig.json b/examples/06-custom-schema/09-container-block/tsconfig.json
similarity index 100%
rename from examples/06-custom-schema/06-toggleable-blocks/tsconfig.json
rename to examples/06-custom-schema/09-container-block/tsconfig.json
diff --git a/examples/06-custom-schema/06-toggleable-blocks/vite-env.d.ts b/examples/06-custom-schema/09-container-block/vite-env.d.ts
similarity index 100%
rename from examples/06-custom-schema/06-toggleable-blocks/vite-env.d.ts
rename to examples/06-custom-schema/09-container-block/vite-env.d.ts
diff --git a/examples/06-custom-schema/06-toggleable-blocks/vite.config.ts b/examples/06-custom-schema/09-container-block/vite.config.ts
similarity index 86%
rename from examples/06-custom-schema/06-toggleable-blocks/vite.config.ts
rename to examples/06-custom-schema/09-container-block/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/06-toggleable-blocks/vite.config.ts
+++ b/examples/06-custom-schema/09-container-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-math-block/src/App.tsx b/examples/06-custom-schema/09-math-block/src/App.tsx
index 554b3402bb..def4ecf3b1 100644
--- a/examples/06-custom-schema/09-math-block/src/App.tsx
+++ b/examples/06-custom-schema/09-math-block/src/App.tsx
@@ -84,7 +84,7 @@ export default function App() {
formattingToolbar={() => (
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/src/App.tsx b/examples/06-custom-schema/10-diagram-block/src/App.tsx
index c96af84261..57998b3f8f 100644
--- a/examples/06-custom-schema/10-diagram-block/src/App.tsx
+++ b/examples/06-custom-schema/10-diagram-block/src/App.tsx
@@ -70,7 +70,7 @@ export default function App() {
formattingToolbar={() => (
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/src/App.tsx b/examples/06-custom-schema/11-source-with-preview/src/App.tsx
index 3485907844..0088d73a82 100644
--- a/examples/06-custom-schema/11-source-with-preview/src/App.tsx
+++ b/examples/06-custom-schema/11-source-with-preview/src/App.tsx
@@ -114,10 +114,11 @@ const createCSVTableBlockSpec = createReactBlockSpec(
// Marks the block as rendering a preview with an editable source popup
// (driven by an editor-wide extension - nothing to register).
hasPreview: true,
- // Enter inserts a newline while the popup is open (multiline source);
- // use "shift+enter" for single-line sources, where Enter closes the
- // popup instead.
- hardBreakShortcut: "enter",
+ },
+ // Enter inserts a newline while the popup is open (multiline source).
+ // Without this, Enter closes the popup, as for single-line sources.
+ experimental_keyboard: {
+ enter: "line-break",
},
render: CSVTablePreview,
},
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..2e0a9c7fcf
--- /dev/null
+++ b/examples/06-custom-schema/13-callout-block/README.md
@@ -0,0 +1,20 @@
+# Callout Block
+
+In this example, we create a custom `Callout` block with a real rich-text title and child blocks inside it (a titled block), like a Notion-style callout.
+
+The block has `content: "inline"`: the title is ordinary inline content — formatting, links, and multiplayer cursors all work — and its child blocks live on `block.children` at runtime. Its `keyboard` settings keep the child blocks inside the callout: Enter in the title adds a first child block, Shift-Tab doesn't move child blocks out, and Enter in an empty last child block leaves the callout. `render` draws the title row and `renderFrame` draws the box around the title and child blocks 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 add a block inside the callout.
+- Press Enter in an empty last block inside the callout to leave the callout.
+- Press Backspace at the start of the first block inside the callout to merge it back into the title.
+- Press "/" inside the callout 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..bb1b66ec1e
--- /dev/null
+++ b/examples/06-custom-schema/13-callout-block/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+ 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/06-toggleable-blocks/package.json b/examples/06-custom-schema/13-callout-block/package.json
similarity index 85%
rename from examples/06-custom-schema/06-toggleable-blocks/package.json
rename to examples/06-custom-schema/13-callout-block/package.json
index caed51c711..9a61db5e93 100644
--- a/examples/06-custom-schema/06-toggleable-blocks/package.json
+++ b/examples/06-custom-schema/13-callout-block/package.json
@@ -1,5 +1,5 @@
{
- "name": "@blocknote/example-custom-schema-toggleable-blocks",
+ "name": "@blocknote/example-custom-schema-callout-block",
"description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
"type": "module",
"private": true,
@@ -19,7 +19,8 @@
"@mantine/core": "^9.0.2",
"@mantine/hooks": "^9.0.2",
"react": "^19.2.3",
- "react-dom": "^19.2.3"
+ "react-dom": "^19.2.3",
+ "react-icons": "^5.5.0"
},
"devDependencies": {
"@types/react": "^19.2.3",
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..842b59e344
--- /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 child blocks inside it.",
+ },
+ {
+ 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 add a block inside the callout, or Backspace at the start of the first block inside to merge it 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..96794b232f
--- /dev/null
+++ b/examples/06-custom-schema/13-callout-block/src/Callout.tsx
@@ -0,0 +1,38 @@
+import { createReactBlockSpec } from "@blocknote/react";
+
+import "./styles.css";
+
+// The Callout block: a titled block. `content: "inline"` gives the block its
+// own rich-text title, and its child blocks sit inside the callout. The
+// `experimental_keyboard` settings keep them inside: Enter in the title adds a first child
+// block, child blocks can't be outdented out, and Enter in an empty last child
+// block leaves the callout. `render` draws the title row (the title mounts
+// into `contentRef`), while `renderFrame` draws the box around the title and
+// the child blocks together.
+export const createCallout = createReactBlockSpec(
+ {
+ type: "callout",
+ propSchema: {},
+ content: "inline",
+ },
+ {
+ experimental_keyboard: {
+ enter: "into-children",
+ childrenCanOutdent: false,
+ emptyChildEnter: "exit-at-end",
+ },
+ 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..b00fcfa71c 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,50 @@ 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",
+ container: true,
+ 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",
+ container: true,
+ children: { allow: ["cell"], min: 2 },
+ }),
+ cell: container("cell", {
+ content: "none",
+ container: true,
+ placeable: "namedOnly",
+ }),
+ } as const,
+});
let editor: BlockNoteEditor;
beforeAll(() => {
- editor = BlockNoteEditor.create() as any;
+ editor = BlockNoteEditor.create({ schema }) as any;
});
afterAll(() => {
@@ -28,21 +66,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 +93,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 41a4fca117..d687be7527 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();
@@ -98,19 +100,10 @@ describe("Test mergeBlocks", () => {
const originalDocument = getEditor().document;
const ret = mergeBlocks(getPosBeforeSelectedBlock());
+ // The empty paragraph above has the same type and props, so the block
+ // moves up into its place, keeping its id (#550).
expect(getEditor().document).toEqual(
- originalDocument
- .filter((block) => block.id !== "paragraph-8")
- .map((block) =>
- block.id === "empty-paragraph"
- ? {
- ...block,
- content: originalDocument.find(
- (source) => source.id === "paragraph-8",
- )!.content,
- }
- : block,
- ),
+ originalDocument.filter((block) => block.id !== "empty-paragraph"),
);
expect(ret).toBe(true);
});
@@ -155,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 e61e9f5139..88bbdb4c02 100644
--- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
@@ -1,5 +1,10 @@
import { Fragment } from "prosemirror-model";
-import { EditorState, Selection, Transaction } from "prosemirror-state";
+import {
+ EditorState,
+ Selection,
+ TextSelection,
+ Transaction,
+} from "prosemirror-state";
import {
type BlockInfo,
@@ -12,21 +17,17 @@ import {
type ContentBlockInfo = Extract;
/**
- * Whether two blocks can merge: both must hold inline content. Merging into
- * or out of container blocks (columnLists, callouts, ...) is intentionally
- * unsupported; the container-boundary Backspace/Delete branches in
- * `KeyboardShortcutsExtension` move blocks across the boundary instead.
+ * Returns the content to append, or undefined when the blocks cannot merge.
+ * Only inline content merges, into inline content: a block with plain-text
+ * content (e.g. a code block) never takes merged text.
*/
-function canMerge(
- prevBlockInfo: BlockInfo,
- nextBlockInfo: BlockInfo,
-): prevBlockInfo is ContentBlockInfo {
- return (
- prevBlockInfo.hasContent &&
- prevBlockInfo.contentKind === "inline" &&
- nextBlockInfo.hasContent &&
- nextBlockInfo.contentKind === "inline"
- );
+export function getMergeContent(
+ current: ContentBlockInfo,
+ next: ContentBlockInfo,
+): Fragment | undefined {
+ return current.contentKind === "inline" && next.contentKind === "inline"
+ ? next.content.node.content
+ : undefined;
}
/** Merge a first child into its parent, promoting descendants into its place. */
@@ -36,15 +37,16 @@ function mergeIntoParent(
parent: ContentBlockInfo,
child: ContentBlockInfo,
): boolean {
- if (!parent.children || !canMerge(parent, child)) {
+ if (!parent.children) {
return false;
}
- const content = child.content.node.content;
+ const content = getMergeContent(parent, child);
if (
- content.size > 0 &&
- !parent.content.node.type.validContent(
- parent.content.node.content.append(content),
- )
+ content === undefined ||
+ (content.size > 0 &&
+ !parent.content.node.type.validContent(
+ parent.content.node.content.append(content),
+ ))
) {
return false;
}
@@ -80,8 +82,10 @@ function mergeIntoParent(
* back from there: the previous sibling's deepest descendant, or the parent
* when the block is its first child.
* @returns A tiptap command that returns `false` (leaving the doc untouched)
- * when the two blocks can't merge: no block above, or either side isn't an
- * inline-content block.
+ * when the two blocks can't merge: no compatible text block above. The block
+ * above may be empty. With the same type and props, the block moves up into
+ * its place. Otherwise the text takes the empty block's type and props (and
+ * id), as in Notion.
*/
export const mergeBlocksCommand =
(posBetweenBlocks: number) =>
@@ -93,16 +97,15 @@ export const mergeBlocksCommand =
dispatch: ((tr: Transaction) => void) | undefined;
}) => {
const nextBlockInfo = getBlockInfoAt(state.doc, posBetweenBlocks);
+ if (!nextBlockInfo.hasContent) {
+ return false;
+ }
- const prevBlockInfo = getPrevBlockInfo(
+ const prevSibling = getPrevBlockInfo(
state.doc,
nextBlockInfo.block.beforePos,
);
-
- if (!prevBlockInfo) {
- if (!nextBlockInfo.hasContent || nextBlockInfo.contentKind !== "inline") {
- return false;
- }
+ if (!prevSibling) {
const parent = getParentBlockInfo(
state.doc,
nextBlockInfo.block.beforePos,
@@ -114,23 +117,39 @@ export const mergeBlocksCommand =
}
// The block we merge into is the last descendant of the previous block:
- // visually, that's the block directly above the boundary. It may be empty:
- // the text then takes its type and props, as in Notion.
- const bottomNestedBlockInfo = getLastDescendantBlockInfo(prevBlockInfo);
+ // visually, that's the block directly above the boundary.
+ const prevBlockInfo = getLastDescendantBlockInfo(prevSibling);
+ if (!prevBlockInfo.hasContent) {
+ return false;
+ }
+ if (getMergeContent(prevBlockInfo, nextBlockInfo) === undefined) {
+ return false;
+ }
+
+ // An empty block above with the same type and props adds nothing: the
+ // block moves up into its place, keeping its id and children (#550).
if (
- !canMerge(bottomNestedBlockInfo, nextBlockInfo) ||
- !nextBlockInfo.hasContent
+ prevBlockInfo.isContentEmpty &&
+ prevBlockInfo.content.node.sameMarkup(nextBlockInfo.content.node)
) {
- return false;
+ if (dispatch) {
+ const tr = state.tr
+ .delete(nextBlockInfo.block.beforePos, nextBlockInfo.block.afterPos)
+ .replaceWith(
+ prevBlockInfo.block.beforePos,
+ prevBlockInfo.block.afterPos,
+ nextBlockInfo.block.node,
+ );
+ tr.setSelection(
+ TextSelection.create(tr.doc, prevBlockInfo.contentStart),
+ );
+ dispatch(tr.scrollIntoView());
+ }
+ return true;
}
- // 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)
@@ -156,10 +175,7 @@ export const mergeBlocksCommand =
// second one to stitch them together.
if (dispatch) {
dispatch(
- state.tr.delete(
- bottomNestedBlockInfo.contentEnd,
- nextBlockInfo.contentStart,
- ),
+ state.tr.delete(prevBlockInfo.contentEnd, nextBlockInfo.contentStart),
);
}
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..0f28679300 100644
--- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
@@ -3,6 +3,9 @@ import { Transaction } from "prosemirror-state";
import { canJoin, liftTarget, ReplaceAroundStep } from "prosemirror-transform";
import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
+import { CHILD_CONTAINER_GROUP } from "../../../../schema/blocks/children.js";
+import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.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 +21,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
);
}
@@ -175,6 +178,9 @@ export function liftItem(
tr: Transaction,
itemType: NodeType,
groupType: NodeType, // change 2
+ // Whether a block may be outdented out of `parent` (its
+ // `keyboard.childrenCanOutdent` setting).
+ canOutdentFrom: (parent: Node) => boolean,
) {
const { $from, $to } = tr.selection;
const range = $from.blockRange($to, (node) => holdsItems(node, itemType)); // change 1
@@ -182,7 +188,14 @@ export function liftItem(
return false;
}
- if ($from.node(range.depth - 1).type === itemType) {
+ const parent = $from.node(range.depth - 1);
+ // A block whose children can't be outdented keeps them: unnesting stops at
+ // its edge rather than lifting the block out of it.
+ if (parent.type === itemType && !canOutdentFrom(parent)) {
+ return false;
+ }
+
+ if (parent.type === itemType) {
// Inside a parent node
return liftToOuterList(tr, itemType, groupType, range); // change 2
}
@@ -198,6 +211,12 @@ function unnestCommand(editor: BlockNoteEditor) {
tr,
editor.pmSchema.nodes["blockContainer"],
editor.pmSchema.nodes["blockGroup"],
+ (parent) => {
+ const block = nodeToBlock(parent, tr.doc);
+ return editor.schema.blockSpecs[block.type].implementation.keyboard(
+ block,
+ ).childrenCanOutdent;
+ },
);
}
@@ -208,8 +227,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..5014b45184 100644
--- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
@@ -27,7 +27,11 @@ 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,
+ isContainerNode,
+} from "../../../../schema/blocks/children.js";
// for compatibility with tiptap. TODO: remove as we want to remove dependency on tiptap command interface
export const updateBlockCommand = <
@@ -70,10 +74,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 +89,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 +111,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 (isContainerNode(newNodeType)) {
+ 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 +194,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 +212,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 +241,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 +265,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 +550,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 +561,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 +669,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..c0ec673e3b
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts
@@ -0,0 +1,101 @@
+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",
+ container: true,
+ },
+ { render: renderDiv },
+)();
+
+const Grid = createBlockSpec(
+ {
+ type: "grid" as const,
+ propSchema: {},
+ content: "none",
+ container: true,
+ children: {
+ allow: ["gridCell"],
+ min: 2,
+ },
+ },
+ { render: renderDiv },
+)();
+
+const GridCell = createBlockSpec(
+ {
+ type: "gridCell" as const,
+ propSchema: {},
+ content: "none",
+ container: true,
+ 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",
+ container: true,
+ children: { min: 2 },
+ },
+ { render: renderDiv },
+)();
+
+// A titled block: an ordinary block with inline content (the title) whose
+// child blocks are a body that belongs to it. Its `experimental_keyboard` settings keep
+// the body together: Enter in the title starts it, its blocks can't be
+// outdented, and an empty last block leaves it. The frame draws the box
+// around title and body together.
+const Alert = createBlockSpec(
+ {
+ type: "alert" as const,
+ propSchema: {},
+ content: "inline",
+ },
+ {
+ experimental_keyboard: {
+ enter: "into-children",
+ childrenCanOutdent: false,
+ emptyChildEnter: "exit-at-end",
+ },
+ 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..faf537dc7b
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/containers.test.ts
@@ -0,0 +1,690 @@
+// @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 keyboard defaults", () => {
+ // A container's children can't be outdented, so its defaults say so: an
+ // empty last child leaves the container instead.
+ it("defaults to leaving the container instead of outdenting", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "callout", type: "callout", children: [{ type: "paragraph" }] },
+ ]);
+ const keyboard = editor.schema.blockSpecs.callout.implementation.keyboard(
+ editor.getBlock("callout")!,
+ );
+ expect(keyboard).toMatchObject({
+ emptyChildEnter: "exit-at-end",
+ childrenCanOutdent: false,
+ });
+ });
+
+ it("keeps outdenting for other blocks", () => {
+ const keyboard = editor.schema.blockSpecs.paragraph.implementation.keyboard(
+ editor.getBlock("p-0")!,
+ );
+ expect(keyboard).toMatchObject({
+ emptyChildEnter: "outdent",
+ childrenCanOutdent: true,
+ });
+ });
+});
+
+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();
+ });
+
+ // Dissolving is an unwrap, not a rewrite: the survivor's content is the same
+ // content, in place, so a caret sitting in it stays where the user put it
+ // (and a collaborator's concurrent edit still maps onto it). Replacing the
+ // container with freshly built copies of its children would collapse the
+ // selection onto the edge of the replaced range instead.
+ it("keeps the caret in the survivor a container unwraps to", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "pair",
+ id: "pair-0",
+ children: [
+ { id: "pair-a", type: "paragraph", content: "A" },
+ { id: "pair-b", type: "paragraph", content: "Survivor" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.setTextCursorPosition("pair-b", "end");
+ const offsetBefore = editor.prosemirrorState.selection.$from.parentOffset;
+
+ editor.removeBlocks(["pair-a"]);
+
+ expect(editor.getBlock("pair-0")).toBeUndefined();
+ expect(editor.getTextCursorPosition().block.id).toBe("pair-b");
+ expect(editor.prosemirrorState.selection.$from.parentOffset).toBe(
+ offsetBefore,
+ );
+ });
+
+ // The same, for a survivor that is lifted out of two wrappers at once.
+ it("keeps the caret in a named-only survivor's content", () => {
+ 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.setTextCursorPosition("cell-b-p", "end");
+ const offsetBefore = editor.prosemirrorState.selection.$from.parentOffset;
+
+ editor.removeBlocks(["cell-a-p"]);
+
+ expect(editor.getBlock("g-0")).toBeUndefined();
+ expect(editor.getTextCursorPosition().block.id).toBe("cell-b-p");
+ expect(editor.prosemirrorState.selection.$from.parentOffset).toBe(
+ offsetBefore,
+ );
+ });
+
+ // 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,
+ container: true,
+ 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..8206bea42e
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts
@@ -0,0 +1,164 @@
+import { Fragment, type Node, Slice } from "prosemirror-model";
+import { type Transaction } from "prosemirror-state";
+import { ReplaceAroundStep } from "prosemirror-transform";
+
+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) {
+ return (node.type.spec.blockConfig?.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 min = container.type.spec.blockConfig?.children?.min ?? 1;
+ const survivors: { node: Node; offset: number }[] = [];
+ const emptied: { from: number; to: number }[] = [];
+ container.forEach((child, offset) => {
+ if (!isEmptyContainerChild(child)) {
+ survivors.push({ node: child, offset });
+ } 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.
+
+ // A single survivor supplying the whole replacement is an unwrap, and doing
+ // it as a `ReplaceAroundStep` leaves that content in place in the document:
+ // positions inside it - a caret, a collaborator's concurrent edit - map
+ // through it, where deleting and reinserting the same content would collapse
+ // them onto the edge of the replaced range.
+ if (survivors.length === 1) {
+ const [{ node: survivor, offset }] = survivors;
+ const survivorPos = containerPos + 1 + offset;
+ // A `namedOnly` survivor is lifted out of two wrappers at once - the column
+ // and the container - and any other survivor out of just the container.
+ const gap = isNamedOnly(survivor.type)
+ ? { from: survivorPos + 1, to: survivorPos + survivor.nodeSize - 1 }
+ : { from: survivorPos, to: survivorPos + survivor.nodeSize };
+
+ tr.step(
+ new ReplaceAroundStep(
+ containerPos,
+ containerPos + container.nodeSize,
+ gap.from,
+ gap.to,
+ Slice.empty,
+ 0,
+ // Not a pure unwrap: emptied siblings sit outside the gap and go with
+ // the container, which the `structure` check would refuse.
+ false,
+ ),
+ );
+ return;
+ }
+
+ // Several survivors are not one contiguous range once an emptied child sits
+ // between them, so this path rebuilds them and positions inside them
+ // collapse. It takes a container with a `min` of 3 or more to reach.
+ const replacement: Node[] = [];
+ for (const { node: 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..8672da659d
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/plainBlocks.test.ts
@@ -0,0 +1,180 @@
+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",
+ },
+ {
+ // A titled block: Enter in its text starts its body, and the body's
+ // blocks can't be outdented out of it.
+ experimental_keyboard: {
+ enter: "into-children",
+ childrenCanOutdent: false,
+ emptyChildEnter: "exit-at-end",
+ },
+ 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 whose Enter goes into their 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("Backspace in the block after merges it into the body's last block", () => {
+ const editor = editorWith();
+ editor.setTextCursorPosition("after", "start");
+ press(editor, "Backspace");
+
+ // As after any block with children: the text is appended to the last
+ // block above it.
+ expect(text(editor, "body")).toBe("ExplanationAfter");
+ expect(editor.document.map((block) => block.id)).toEqual(["note"]);
+ });
+
+ it("keeps its children inside 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..a151e1e33a
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/titledBlocks.test.ts
@@ -0,0 +1,365 @@
+import { NodeSelection, TextSelection } from "prosemirror-state";
+import { afterEach, describe, expect, it } from "vite-plus/test";
+
+import type { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js";
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+import { 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("is an ordinary block whose keyboard settings keep its body together", () => {
+ const editor = editorWith(withAlert());
+
+ function keyboardOf(id: string) {
+ const { blockSpecs } = editor.schema as BlockNoteSchema;
+ const block = editor.getBlock(id)!;
+ return blockSpecs[block.type].implementation.keyboard(block);
+ }
+
+ 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(keyboardOf("w")).toMatchObject({
+ enter: "into-children",
+ childrenCanOutdent: false,
+ emptyChildEnter: "exit-at-end",
+ });
+ expect(alert.node.attrs.id).toBe("w");
+ expect(alert.node.firstChild!.attrs).not.toHaveProperty("id");
+
+ expect(keyboardOf("pre")).toMatchObject({
+ enter: "split",
+ childrenCanOutdent: true,
+ emptyChildEnter: "outdent",
+ });
+
+ // 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 merges it into the body's last block", () => {
+ const editor = editorWith(withAlert());
+ editor.setTextCursorPosition("post", "start");
+ press(editor, "Backspace");
+
+ // As after any block with children: the text is appended to the last
+ // block above it.
+ expect(shape(editor.document)).toBe(
+ 'paragraph"Before", alert"Title"[paragraph"One", paragraph"TwoAfter"]',
+ );
+ });
+
+ 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/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts
index 33376b2835..05338f5f97 100644
--- a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts
+++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts
@@ -60,11 +60,11 @@ const makeCheckListItemsReadOnly = (element: HTMLElement) => {
// serializing HTML elements to a string, so the button no longer works if the
// HTML string is rendered out.
const forceToggleBlocksShow = (element: HTMLElement) => {
- const hiddenToggleWrappers = element.querySelectorAll(
- '.bn-toggle-wrapper[data-show-children="false"]',
+ const closedToggleButtons = element.querySelectorAll(
+ '.bn-toggle-button[aria-expanded="false"]',
);
- hiddenToggleWrappers.forEach((toggleWrapper) => {
- toggleWrapper.setAttribute("data-show-children", "true");
+ closedToggleButtons.forEach((toggleButton) => {
+ toggleButton.setAttribute("aria-expanded", "true");
});
return element;
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..1ea7605bab 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,
@@ -14,6 +15,7 @@ import {
} from "../../../nodeConversions/blockToNode.js";
import { nodeToCustomInlineContent } from "../../../nodeConversions/nodeToBlock.js";
+import { setBlockColorAttributes } from "../../../../pm-nodes/BlockContainer.js";
export function serializeInlineContentInternalHTML<
BSchema extends BlockSchema,
I extends InlineContentSchema,
@@ -159,6 +161,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 +174,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,
@@ -196,11 +206,34 @@ function serializeBlock<
dom: HTMLElement;
contentDOM?: HTMLElement;
};
+ if (bc.contentDOM) {
+ setBlockColorAttributes(bc.contentDOM, props);
+ }
- 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..ddd0d00b7c 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,139 @@ 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 content 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.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..822fc14044 100644
--- a/packages/core/src/api/getBlockInfoFromPos.ts
+++ b/packages/core/src/api/getBlockInfoFromPos.ts
@@ -7,6 +7,10 @@ import {
Transaction,
} from "prosemirror-state";
+import {
+ CHILD_CONTAINER_GROUP,
+ isContainerNode,
+} from "../schema/blocks/children.js";
import type { BlockConfig } from "../schema/blocks/types.js";
/**
@@ -319,7 +323,7 @@ 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,
block,
@@ -452,7 +456,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 +587,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 +597,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/Heading/block.ts b/packages/core/src/blocks/Heading/block.ts
index 6b14204cc8..d523888601 100644
--- a/packages/core/src/blocks/Heading/block.ts
+++ b/packages/core/src/blocks/Heading/block.ts
@@ -7,7 +7,10 @@ import {
parseDefaultProps,
} from "../defaultProps.js";
import { getDetailsContent } from "../getDetailsContent.js";
-import { createToggleWrapper } from "../ToggleWrapper/createToggleWrapper.js";
+import {
+ createToggleFrame,
+ isToggleOpen,
+} from "../ToggleWrapper/createToggleFrame.js";
const HEADING_LEVELS = [1, 2, 3, 4, 5, 6] as const;
@@ -18,8 +21,15 @@ export interface HeadingOptions {
allowToggleHeadings?: boolean;
}
+// A regular heading's props. With toggle headings enabled, this sets
+// `isToggleable: false`, so that turning a toggle heading into a heading of
+// some level also makes it a regular heading (BLO-959).
+function regularHeadingProps(level: number, allowToggleHeadings: boolean) {
+ return allowToggleHeadings ? { level, isToggleable: false } : { level };
+}
+
const createHeadingKeyboardShortcut =
- (level: number) =>
+ (level: number, allowToggleHeadings: boolean) =>
({ editor }: { editor: BlockNoteEditor }) => {
const cursorPosition = editor.getTextCursorPosition();
@@ -31,7 +41,7 @@ const createHeadingKeyboardShortcut =
editor.updateBlock(cursorPosition.block, {
type: "heading",
- props: { level },
+ props: regularHeadingProps(level, allowToggleHeadings),
});
return true;
@@ -63,7 +73,27 @@ export const createHeadingBlockSpec = createBlockSpec(
({ allowToggleHeadings = true }: HeadingOptions = {}) => ({
meta: {
isolating: false,
+ // A block dragged onto a toggle heading becomes its first child.
+ dropsIntoChildren: (block) =>
+ allowToggleHeadings && block.props.isToggleable === true,
},
+ // A toggle heading resets to a regular heading, which in turn resets to a
+ // paragraph. While a toggle heading is open, Enter in its text starts its
+ // children, and Enter in an empty child adds another child.
+ experimental_keyboard: allowToggleHeadings
+ ? (block) => {
+ if (!block.props.isToggleable) {
+ return {};
+ }
+ const open = isToggleOpen(block);
+ return {
+ resetsTo: { type: "heading", props: { isToggleable: false } },
+ emptyEnterResets: true,
+ enter: open ? "into-children" : "split",
+ emptyChildEnter: open ? "stay" : "outdent",
+ };
+ }
+ : undefined,
parse(e) {
if (allowToggleHeadings && e.tagName === "DETAILS") {
const summary = e.querySelector(":scope > summary");
@@ -126,19 +156,25 @@ export const createHeadingBlockSpec = createBlockSpec(
}
: {}),
runsBefore: ["toggleListItem"],
- render(block, editor) {
+ render(block) {
const dom = document.createElement(`h${block.props.level}`);
-
- if (allowToggleHeadings) {
- const toggleWrapper = createToggleWrapper(block, editor, dom);
- return { ...toggleWrapper, contentDOM: dom };
- }
-
return {
dom,
contentDOM: dom,
};
},
+ renderFrame(block, editor) {
+ if (!allowToggleHeadings || !block.props.isToggleable) {
+ return undefined;
+ }
+ const frame = createToggleFrame(block, editor);
+ return {
+ ...frame,
+ // A heading that is no longer toggleable gets no frame.
+ update: (updated) =>
+ !!updated.props.isToggleable && frame.update(updated),
+ };
+ },
toExternalHTML(block) {
const dom = document.createElement(`h${block.props.level}`);
addDefaultPropsExternalHTML(block.props, dom);
@@ -163,13 +199,16 @@ export const createHeadingBlockSpec = createBlockSpec(
};
},
}),
- ({ levels = HEADING_LEVELS }: HeadingOptions = {}) => [
+ ({
+ levels = HEADING_LEVELS,
+ allowToggleHeadings = true,
+ }: HeadingOptions = {}) => [
createExtension({
key: "heading-shortcuts",
keyboardShortcuts: Object.fromEntries(
levels.map((level) => [
`Mod-Alt-${level}`,
- createHeadingKeyboardShortcut(level),
+ createHeadingKeyboardShortcut(level, allowToggleHeadings),
]) ?? [],
),
inputRules: levels.map((level) => ({
@@ -177,9 +216,7 @@ export const createHeadingBlockSpec = createBlockSpec(
replace({ match }: { match: RegExpMatchArray }) {
return {
type: "heading",
- props: {
- level: match[1].length,
- },
+ props: regularHeadingProps(match[1].length, allowToggleHeadings),
};
},
})),
diff --git a/packages/core/src/blocks/ListItem/BulletListItem/block.ts b/packages/core/src/blocks/ListItem/BulletListItem/block.ts
index 0a40bdc1ce..6c791299fb 100644
--- a/packages/core/src/blocks/ListItem/BulletListItem/block.ts
+++ b/packages/core/src/blocks/ListItem/BulletListItem/block.ts
@@ -6,7 +6,6 @@ import {
defaultProps,
parseDefaultProps,
} from "../../defaultProps.js";
-import { handleEnter } from "../../utils/listItemEnterHandler.js";
import { getListItemContent } from "../getListItemContent.js";
export type BulletListItemBlockConfig = ReturnType<
@@ -27,6 +26,12 @@ export const createBulletListItemBlockConfig = createBlockConfig(
export const createBulletListItemBlockSpec = createBlockSpec(
createBulletListItemBlockConfig,
{
+ // Enter continues the list, and Enter in an empty item ends it: the item
+ // turns into a paragraph.
+ experimental_keyboard: {
+ splitKeepsType: true,
+ emptyEnterResets: true,
+ },
meta: {
isolating: false,
},
@@ -84,9 +89,6 @@ export const createBulletListItemBlockSpec = createBlockSpec(
createExtension({
key: "bullet-list-item-shortcuts",
keyboardShortcuts: {
- Enter: ({ editor }) => {
- return handleEnter(editor, "bulletListItem");
- },
"Mod-Shift-8": ({ editor }) => {
const cursorPosition = editor.getTextCursorPosition();
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/blocks/ListItem/CheckListItem/block.ts b/packages/core/src/blocks/ListItem/CheckListItem/block.ts
index 6d514270bf..936efc3be0 100644
--- a/packages/core/src/blocks/ListItem/CheckListItem/block.ts
+++ b/packages/core/src/blocks/ListItem/CheckListItem/block.ts
@@ -5,7 +5,6 @@ import {
defaultProps,
parseDefaultProps,
} from "../../defaultProps.js";
-import { handleEnter } from "../../utils/listItemEnterHandler.js";
import { getListItemContent } from "../getListItemContent.js";
export type CheckListItemBlockConfig = ReturnType<
@@ -27,6 +26,12 @@ export const createCheckListItemConfig = createBlockConfig(
export const createCheckListItemBlockSpec = createBlockSpec(
createCheckListItemConfig,
{
+ // Enter continues the list, and Enter in an empty item ends it: the item
+ // turns into a paragraph.
+ experimental_keyboard: {
+ splitKeepsType: true,
+ emptyEnterResets: true,
+ },
meta: {
isolating: false,
},
@@ -133,9 +138,6 @@ export const createCheckListItemBlockSpec = createBlockSpec(
createExtension({
key: "check-list-item-shortcuts",
keyboardShortcuts: {
- Enter: ({ editor }) => {
- return handleEnter(editor, "checkListItem");
- },
"Mod-Shift-9": ({ editor }) => {
const cursorPosition = editor.getTextCursorPosition();
diff --git a/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts b/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts
deleted file mode 100644
index 218618ca1f..0000000000
--- a/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { splitBlockCommand } from "../../api/blockManipulation/commands/splitBlock/splitBlock.js";
-import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
-import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js";
-import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
-
-export const handleEnter = (editor: BlockNoteEditor) => {
- const { blockInfo, selectionEmpty } = editor.transact((tr) => {
- return {
- blockInfo: getBlockInfoFromSelection(tr),
- selectionEmpty: tr.selection.anchor === tr.selection.head,
- };
- });
-
- if (!blockInfo.hasContent) {
- return false;
- }
- const { block: blockContainer, content } = blockInfo;
-
- if (
- !(
- content.node.type.name === "toggleListItem" ||
- content.node.type.name === "bulletListItem" ||
- content.node.type.name === "numberedListItem" ||
- content.node.type.name === "checkListItem"
- ) ||
- !selectionEmpty
- ) {
- return false;
- }
-
- return editor._tiptapEditor.commands.first(({ state, chain, commands }) => [
- () =>
- // Changes list item block to a paragraph block if the content is empty.
- commands.command(() => {
- if (blockInfo.isContentEmpty) {
- return commands.command(
- updateBlockCommand(blockContainer.beforePos, {
- type: "paragraph",
- props: {},
- }),
- );
- }
-
- return false;
- }),
-
- () =>
- // Splits the current block, moving content inside that's after the cursor
- // to a new block of the same type below.
- commands.command(() => {
- if (content.node.childCount > 0) {
- chain()
- .deleteSelection()
- .command(splitBlockCommand(state.selection.from, true))
- .run();
-
- return true;
- }
-
- return false;
- }),
- ]);
-};
diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts
index fc2537829d..57530d741b 100644
--- a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts
+++ b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts
@@ -6,7 +6,6 @@ import {
defaultProps,
parseDefaultProps,
} from "../../defaultProps.js";
-import { handleEnter } from "../../utils/listItemEnterHandler.js";
import { getListItemContent } from "../getListItemContent.js";
import { NumberedListIndexingDecorationPlugin } from "./IndexingPlugin.js";
@@ -29,6 +28,12 @@ export const createNumberedListItemBlockConfig = createBlockConfig(
export const createNumberedListItemBlockSpec = createBlockSpec(
createNumberedListItemBlockConfig,
{
+ // Enter continues the list, and Enter in an empty item ends it: the item
+ // turns into a paragraph.
+ experimental_keyboard: {
+ splitKeepsType: true,
+ emptyEnterResets: true,
+ },
meta: {
isolating: false,
},
@@ -115,9 +120,6 @@ export const createNumberedListItemBlockSpec = createBlockSpec(
},
],
keyboardShortcuts: {
- Enter: ({ editor }) => {
- return handleEnter(editor, "numberedListItem");
- },
"Mod-Shift-7": ({ editor }) => {
const cursorPosition = editor.getTextCursorPosition();
diff --git a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts
index 54a7a39dfe..031203dd5a 100644
--- a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts
+++ b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts
@@ -6,8 +6,10 @@ import {
parseDefaultProps,
} from "../../defaultProps.js";
import { getDetailsContent } from "../../getDetailsContent.js";
-import { createToggleWrapper } from "../../ToggleWrapper/createToggleWrapper.js";
-import { handleEnter } from "../../utils/listItemEnterHandler.js";
+import {
+ createToggleFrame,
+ isToggleOpen,
+} from "../../ToggleWrapper/createToggleFrame.js";
export type ToggleListItemBlockConfig = ReturnType<
typeof createToggleListItemBlockConfig
@@ -27,8 +29,22 @@ export const createToggleListItemBlockConfig = createBlockConfig(
export const createToggleListItemBlockSpec = createBlockSpec(
createToggleListItemBlockConfig,
{
+ // Enter continues the list, and Enter in an empty item ends it: the item
+ // turns into a paragraph. While the toggle is open, Enter in its text
+ // starts its children, and Enter in an empty child adds another child.
+ experimental_keyboard: (block) => {
+ const open = isToggleOpen(block);
+ return {
+ splitKeepsType: true,
+ emptyEnterResets: true,
+ enter: open ? "into-children" : "split",
+ emptyChildEnter: open ? "stay" : "outdent",
+ };
+ },
meta: {
isolating: false,
+ // A block dragged onto the toggle becomes its first child.
+ dropsIntoChildren: () => true,
},
parse(element) {
if (element.tagName === "DETAILS") {
@@ -71,15 +87,11 @@ export const createToggleListItemBlockSpec = createBlockSpec(
);
},
runsBefore: ["bulletListItem"],
- render(block, editor) {
+ render() {
const paragraphEl = document.createElement("p");
- const toggleWrapper = createToggleWrapper(
- block as any,
- editor,
- paragraphEl,
- );
- return { ...toggleWrapper, contentDOM: paragraphEl };
+ return { dom: paragraphEl, contentDOM: paragraphEl };
},
+ renderFrame: createToggleFrame,
toExternalHTML(block) {
const li = document.createElement("li");
const details = document.createElement("details");
@@ -103,9 +115,6 @@ export const createToggleListItemBlockSpec = createBlockSpec(
createExtension({
key: "toggle-list-item-shortcuts",
keyboardShortcuts: {
- Enter: ({ editor }) => {
- return handleEnter(editor, "toggleListItem");
- },
"Mod-Shift-6": ({ editor }) => {
const cursorPosition = editor.getTextCursorPosition();
diff --git a/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts b/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts
new file mode 100644
index 0000000000..f77b837eee
--- /dev/null
+++ b/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts
@@ -0,0 +1,126 @@
+import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import type { Block } from "../defaultBlocks.js";
+
+// Only the block's id is used, so anything with an id will do.
+type ToggledState = {
+ set: (block: Pick, "id">, isToggled: boolean) => void;
+ get: (block: Pick, "id">) => boolean;
+};
+
+export const defaultToggledState: ToggledState = {
+ set: (block, isToggled: boolean) =>
+ window.localStorage.setItem(
+ `toggle-${block.id}`,
+ isToggled ? "true" : "false",
+ ),
+ get: (block) => window.localStorage.getItem(`toggle-${block.id}`) === "true",
+};
+
+// https://fonts.google.com/icons?selected=Material+Symbols+Rounded:chevron_right:FILL@0;wght@700;GRAD@0;opsz@24&icon.query=chevron&icon.style=Rounded&icon.size=24&icon.color=%23e8eaed
+// `aria-hidden`: the icon is decorative, and the button has its own name.
+const chevronIcon =
+ '';
+
+/**
+ * Whether the toggle block is open, as its frame shows it. For keyboard
+ * settings that differ between an open and a closed toggle.
+ */
+export function isToggleOpen(block: { id: string }) {
+ return defaultToggledState.get(block);
+}
+
+/**
+ * The frame of a toggle block, for `renderFrame`: a chevron that shows or
+ * hides the block's children, and an "Add block" button while the toggle is
+ * open and has no children. BlockNote puts the block's content and its
+ * children in the slot; hiding the children is a CSS rule on the frame.
+ *
+ * Whether a toggle is open is the reader's view state, kept in
+ * `toggledState` (per browser by default), not in the document.
+ */
+export function createToggleFrame(
+ block: Block,
+ editor: BlockNoteEditor,
+ toggledState = defaultToggledState,
+) {
+ const dom = document.createElement("div");
+ dom.className = "bn-toggle-wrapper bn-toggle-frame";
+
+ // Chrome outside the slot handles its own events. Cancelling `mousedown`
+ // keeps a click from moving the caret.
+ const toggleButton = document.createElement("button");
+ toggleButton.className = "bn-toggle-button";
+ toggleButton.type = "button";
+ // A fixed name, with the state in `aria-expanded`, so a screen reader
+ // announces e.g. "Expand or collapse, button, collapsed" (#2811). The CSS
+ // reads the open state from `aria-expanded` too.
+ toggleButton.setAttribute(
+ "aria-label",
+ editor.dictionary.toggle_blocks.toggle_button,
+ );
+ toggleButton.innerHTML = chevronIcon;
+ toggleButton.addEventListener("mousedown", (event) => event.preventDefault());
+
+ const slot = document.createElement("div");
+ slot.className = "bn-toggle-slot";
+
+ const addBlockButton = document.createElement("button");
+ addBlockButton.className = "bn-toggle-add-block-button";
+ addBlockButton.type = "button";
+ addBlockButton.textContent = editor.dictionary.toggle_blocks.add_block_button;
+ addBlockButton.addEventListener("mousedown", (event) =>
+ event.preventDefault(),
+ );
+ addBlockButton.addEventListener("click", () => {
+ editor.transact(() => {
+ // A single empty block of the default type.
+ const updated = editor.updateBlock(block.id, { children: [{}] });
+ editor.setTextCursorPosition(updated.children[0], "end");
+ editor.focus();
+ });
+ });
+
+ dom.append(toggleButton, slot);
+
+ let childCount = block.children.length;
+ let open = toggledState.get(block);
+
+ function show() {
+ toggleButton.setAttribute("aria-expanded", String(open));
+ const showAddBlock = open && childCount === 0 && editor.isEditable;
+ if (showAddBlock && !addBlockButton.isConnected) {
+ dom.append(addBlockButton);
+ } else if (!showAddBlock) {
+ addBlockButton.remove();
+ }
+ }
+
+ toggleButton.addEventListener("click", () => {
+ open = !open;
+ toggledState.set(block, open);
+ show();
+ });
+
+ show();
+
+ return {
+ dom,
+ slot,
+ // Keeps the frame, and so its open state, when the block changes. Adding
+ // a child opens the toggle. Removing the last child keeps it open, showing
+ // the "Add block" button, as in Notion.
+ update(updated: Block) {
+ const newChildCount = updated.children.length;
+ const wasOpen = open;
+ if (newChildCount > childCount) {
+ open = true;
+ }
+ if (open !== wasOpen) {
+ toggledState.set(updated, open);
+ }
+ childCount = newChildCount;
+ show();
+ return true;
+ },
+ };
+}
diff --git a/packages/core/src/blocks/ToggleWrapper/createToggleWrapper.ts b/packages/core/src/blocks/ToggleWrapper/createToggleWrapper.ts
deleted file mode 100644
index 257fd7ce6f..0000000000
--- a/packages/core/src/blocks/ToggleWrapper/createToggleWrapper.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-import { ViewMutationRecord } from "@tiptap/pm/view";
-
-import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
-import { Block } from "../defaultBlocks.js";
-
-type ToggledState = {
- set: (block: Block, isToggled: boolean) => void;
- get: (block: Block) => boolean;
-};
-
-export const defaultToggledState: ToggledState = {
- set: (block, isToggled: boolean) =>
- window.localStorage.setItem(
- `toggle-${block.id}`,
- isToggled ? "true" : "false",
- ),
- get: (block) => window.localStorage.getItem(`toggle-${block.id}`) === "true",
-};
-
-export const createToggleWrapper = (
- block: Block,
- editor: BlockNoteEditor,
- renderedElement: HTMLElement,
- toggledState: ToggledState = defaultToggledState,
-): {
- dom: HTMLElement;
- contentDOM?: HTMLElement;
- ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
- destroy?: () => void;
-} => {
- if ("isToggleable" in block.props && !block.props.isToggleable) {
- return {
- dom: renderedElement,
- };
- }
-
- const dom = document.createElement("div");
-
- const toggleWrapper = document.createElement("div");
- toggleWrapper.className = "bn-toggle-wrapper";
-
- const toggleButton = document.createElement("button");
- toggleButton.className = "bn-toggle-button";
- toggleButton.type = "button";
- toggleButton.innerHTML =
- // https://fonts.google.com/icons?selected=Material+Symbols+Rounded:chevron_right:FILL@0;wght@700;GRAD@0;opsz@24&icon.query=chevron&icon.style=Rounded&icon.size=24&icon.color=%23e8eaed
- '';
- const toggleButtonMouseDown = (event: MouseEvent) => event.preventDefault();
- toggleButton.addEventListener("mousedown", toggleButtonMouseDown);
- const toggleButtonOnClick = () => {
- // Toggles visibility of child blocks. Also adds/removes the "add block"
- // button if there are no child blocks.
- const currentBlock = editor.getBlock(block);
- if (!currentBlock) {
- return;
- }
-
- if (toggleWrapper.getAttribute("data-show-children") === "true") {
- toggleWrapper.setAttribute("data-show-children", "false");
- toggledState.set(currentBlock, false);
-
- if (dom.contains(toggleAddBlockButton)) {
- dom.removeChild(toggleAddBlockButton);
- }
- } else {
- toggleWrapper.setAttribute("data-show-children", "true");
- toggledState.set(currentBlock, true);
-
- if (
- editor.isEditable &&
- currentBlock.children.length === 0 &&
- !dom.contains(toggleAddBlockButton)
- ) {
- dom.appendChild(toggleAddBlockButton);
- }
- }
- };
- toggleButton.addEventListener("click", toggleButtonOnClick);
-
- toggleWrapper.appendChild(toggleButton);
- toggleWrapper.appendChild(renderedElement);
-
- const toggleAddBlockButton = document.createElement("button");
- toggleAddBlockButton.className = "bn-toggle-add-block-button";
- toggleAddBlockButton.type = "button";
- toggleAddBlockButton.textContent =
- editor.dictionary.toggle_blocks.add_block_button;
- const toggleAddBlockButtonMouseDown = (event: MouseEvent) =>
- event.preventDefault();
- toggleAddBlockButton.addEventListener(
- "mousedown",
- toggleAddBlockButtonMouseDown,
- );
- const toggleAddBlockButtonOnClick = () => {
- // Adds a single empty child block.
- editor.transact(() => {
- // dom.removeChild(toggleAddBlockButton);
-
- const updatedBlock = editor.updateBlock(block, {
- // Single empty block with default type.
- children: [{}],
- });
- editor.setTextCursorPosition(updatedBlock.children[0].id, "end");
- editor.focus();
- });
- };
- toggleAddBlockButton.addEventListener("click", toggleAddBlockButtonOnClick);
-
- dom.appendChild(toggleWrapper);
-
- let childCount = block.children.length;
- const onEditorChange = editor.onChange(() => {
- const newChildCount = editor.getBlock(block)?.children.length ?? 0;
-
- if (newChildCount > childCount) {
- // If a child block is added while children are hidden, show children.
- if (toggleWrapper.getAttribute("data-show-children") === "false") {
- toggleWrapper.setAttribute("data-show-children", "true");
- const currentBlock = editor.getBlock(block);
- if (currentBlock) {
- toggledState.set(currentBlock, true);
- }
- }
-
- // Remove the "add block" button as we want to show child blocks and
- // there is at least one child block.
- if (dom.contains(toggleAddBlockButton)) {
- dom.removeChild(toggleAddBlockButton);
- }
- } else if (newChildCount === 0 && newChildCount < childCount) {
- // If the last child block is removed while children are shown, hide
- // children.
- if (toggleWrapper.getAttribute("data-show-children") === "true") {
- toggleWrapper.setAttribute("data-show-children", "false");
- const currentBlock = editor.getBlock(block);
- if (currentBlock) {
- toggledState.set(currentBlock, false);
- }
- }
-
- // Remove the "add block" button as we want to hide child blocks,
- // regardless of whether there are child blocks or not.
- if (dom.contains(toggleAddBlockButton)) {
- dom.removeChild(toggleAddBlockButton);
- }
- }
-
- childCount = newChildCount;
- });
-
- if (toggledState.get(block)) {
- toggleWrapper.setAttribute("data-show-children", "true");
-
- if (editor.isEditable && block.children.length === 0) {
- // If the toggle is set to show children, but there are no children,
- // we add the "add block" button.
- dom.appendChild(toggleAddBlockButton);
- }
- } else {
- toggleWrapper.setAttribute("data-show-children", "false");
- }
-
- return {
- dom,
- // Prevents re-renders when the toggle button is clicked.
- ignoreMutation: (mutation) => {
- if (
- mutation instanceof MutationRecord &&
- // We want to prevent re-renders when the view changes, so we ignore
- // all mutations where the `data-show-children` attribute is changed
- // or the "add block" button is added/removed.
- ((mutation.type === "attributes" &&
- mutation.target === toggleWrapper &&
- mutation.attributeName === "data-show-children") ||
- (mutation.type === "childList" &&
- (mutation.addedNodes[0] === toggleAddBlockButton ||
- mutation.removedNodes[0] === toggleAddBlockButton)))
- ) {
- return true;
- }
- return false;
- },
- destroy: () => {
- toggleButton.removeEventListener("mousedown", toggleButtonMouseDown);
- toggleButton.removeEventListener("click", toggleButtonOnClick);
- toggleAddBlockButton.removeEventListener(
- "mousedown",
- toggleAddBlockButtonMouseDown,
- );
- toggleAddBlockButton.removeEventListener(
- "click",
- toggleAddBlockButtonOnClick,
- );
- onEditorChange?.();
- },
- };
-};
diff --git a/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts b/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts
new file mode 100644
index 0000000000..b8d932d189
--- /dev/null
+++ b/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts
@@ -0,0 +1,1056 @@
+import { Fragment, Slice } from "prosemirror-model";
+import { TextSelection } from "prosemirror-state";
+import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
+import { page, userEvent } from "vite-plus/test/browser";
+
+import "../../style.css";
+import { getNodeById } from "../../api/nodeUtil.js";
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { getDefaultSlashMenuItems } from "../../extensions/SuggestionMenu/getDefaultSlashMenuItems.js";
+import type { PartialBlock } from "../defaultBlocks.js";
+
+// Behaviour of the built-in toggle blocks (toggle heading and toggle list
+// item), including regressions reported under BLO-1018. Every test runs for
+// both blocks. The expected behaviour is Notion's (compared on 2026-09-29),
+// with one deliberate exception: a toggle heading turned into a regular
+// heading keeps its children nested, where Notion moves them out. Tests for
+// behaviour BlockNote does not match yet use `it.fails`: they state the
+// expected behaviour, so they start failing - and must be switched to `it` -
+// once it is implemented.
+
+const MOD = navigator.platform.includes("Mac") ? "Meta" : "Control";
+
+const kinds = [
+ {
+ name: "toggle heading",
+ toggle: (
+ id: string,
+ content: string,
+ children: PartialBlock[] = [],
+ ): PartialBlock => ({
+ id,
+ type: "heading",
+ props: { level: 2, isToggleable: true },
+ content,
+ children,
+ }),
+ newBlockTypeAfterClosedToggle: "paragraph",
+ },
+ {
+ name: "toggle list item",
+ toggle: (
+ id: string,
+ content: string,
+ children: PartialBlock[] = [],
+ ): PartialBlock => ({
+ id,
+ type: "toggleListItem",
+ content,
+ children,
+ }),
+ newBlockTypeAfterClosedToggle: "toggleListItem",
+ },
+];
+
+let editor: BlockNoteEditor;
+let root: HTMLElement;
+
+function mount(content: PartialBlock[], options = { editable: true }) {
+ root = document.createElement("div");
+ document.body.appendChild(root);
+ editor = BlockNoteEditor.create({ initialContent: content });
+ editor.isEditable = options.editable;
+ editor.mount(root);
+}
+
+/** Replaces the editor with a new one, as a page reload would. */
+function remount(content: PartialBlock[], options = { editable: true }) {
+ editor._tiptapEditor.destroy();
+ root.remove();
+ mount(content, options);
+}
+
+beforeEach(() => {
+ // The open state of a toggle is kept in `localStorage`, keyed by block id.
+ localStorage.clear();
+});
+
+afterEach(() => {
+ editor._tiptapEditor.destroy();
+ root.remove();
+});
+
+/** The block's type, text and children, e.g. `heading"Title"[paragraph"One"]`. */
+function shape(blocks = editor.document): string {
+ return blocks
+ .map((block) => {
+ const text = Array.isArray(block.content)
+ ? block.content.map((c) => ("text" in c ? c.text : "")).join("")
+ : "";
+ const children = block.children.length
+ ? `[${shape(block.children)}]`
+ : "";
+ return `${block.type}"${text}"${children}`;
+ })
+ .join(", ");
+}
+
+function blockElement(id: string) {
+ const element = root.querySelector(`.bn-block[data-id="${id}"]`);
+ if (!element) {
+ throw new Error(`Block "${id}" is not rendered`);
+ }
+ return element as HTMLElement;
+}
+
+/**
+ * The first element matching `selector` that belongs to the block itself,
+ * not to one of its children. Where it sits inside the block is left open.
+ */
+function own(id: string, selector: string) {
+ const block = blockElement(id);
+ return (
+ [...block.querySelectorAll(selector)].find(
+ (element) => element.closest(".bn-block") === block,
+ ) ?? null
+ );
+}
+
+function toggleButton(id: string) {
+ return own(id, ".bn-toggle-button");
+}
+
+function addBlockButton(id: string) {
+ return own(id, ".bn-toggle-add-block-button");
+}
+
+/** Whether the toggle is open, as its chevron announces it. */
+function isOpen(id: string) {
+ const button = toggleButton(id);
+ if (!button) {
+ throw new Error(`Block "${id}" is not a toggle`);
+ }
+ return button.getAttribute("aria-expanded") === "true";
+}
+
+function childrenAreVisible(id: string) {
+ const group = own(id, ".bn-block-group");
+ if (!group) {
+ throw new Error(`Block "${id}" has no children`);
+ }
+ return getComputedStyle(group).display !== "none";
+}
+
+async function open(id: string) {
+ await userEvent.click(toggleButton(id)!);
+}
+
+/**
+ * Drags block `id` over the center of `target`, as the side menu starts a
+ * block drag. The events are synthetic: an emulated mouse drag does not reach
+ * every target reliably. `drop()` drops the block there.
+ */
+function dragBlockOver(id: string, target: Element) {
+ const view = editor.prosemirrorView;
+ const { node } = getNodeById(id, view.state.doc)!;
+ view.dragging = { slice: new Slice(Fragment.from(node), 0, 0), move: true };
+ // The side menu also puts the blocks in the drag data, which marks the drop
+ // as a block drop for the editor's drop handlers.
+ const dataTransfer = new DataTransfer();
+ dataTransfer.setData("blocknote/html", "");
+ const rect = target.getBoundingClientRect();
+ const init: DragEventInit = {
+ bubbles: true,
+ cancelable: true,
+ clientX: rect.x + rect.width / 2,
+ clientY: rect.y + rect.height / 2,
+ dataTransfer,
+ };
+ const element = document.elementFromPoint(init.clientX!, init.clientY!)!;
+ element.dispatchEvent(new DragEvent("dragenter", init));
+ element.dispatchEvent(new DragEvent("dragover", init));
+ return {
+ // At the element under the point at drop time, as the browser does.
+ drop: () =>
+ document
+ .elementFromPoint(init.clientX!, init.clientY!)!
+ .dispatchEvent(new DragEvent("drop", init)),
+ };
+}
+
+function dropCursor() {
+ return document.querySelector('[class*="prosemirror-dropcursor"]');
+}
+
+/** The id of the block highlighted as the one a drop goes into. */
+function highlightedDropTarget() {
+ return (
+ root
+ .querySelector('[data-drop-target="true"] > .bn-block')
+ ?.getAttribute("data-id") ?? null
+ );
+}
+
+/** Puts the caret in the block and presses the keys. */
+async function press(
+ keys: string,
+ at: { block: string; placement: "start" | "end" },
+) {
+ editor.setTextCursorPosition(at.block, at.placement);
+ editor.focus();
+ await userEvent.keyboard(keys);
+}
+
+/**
+ * Puts the caret after the first `offset` characters of the block's text.
+ * BlockNote's API can only put the caret at the start or end of a block.
+ */
+function setCaretAt(id: string, offset: number) {
+ editor.transact((tr) => {
+ const block = getNodeById(id, tr.doc)!;
+ // Inside the block, then inside its content.
+ const textStart = block.posBeforeNode + 2;
+ tr.setSelection(TextSelection.create(tr.doc, textStart + offset));
+ });
+ editor.focus();
+}
+
+describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => {
+ const withChildren = (): PartialBlock[] => [
+ toggle("t", "Title", [
+ { id: "c1", type: "paragraph", content: "One" },
+ { id: "c2", type: "paragraph", content: "Two" },
+ ]),
+ { id: "after", type: "paragraph", content: "After" },
+ ];
+
+ describe("open state", () => {
+ it("starts closed, with its children hidden", () => {
+ mount(withChildren());
+
+ expect(isOpen("t")).toBe(false);
+ expect(childrenAreVisible("t")).toBe(false);
+ });
+
+ it("shows and hides its children with the chevron, without changing the document", async () => {
+ mount(withChildren());
+ const document = JSON.stringify(editor.document);
+
+ await open("t");
+ expect(childrenAreVisible("t")).toBe(true);
+
+ await open("t");
+ expect(childrenAreVisible("t")).toBe(false);
+ expect(JSON.stringify(editor.document)).toBe(document);
+ });
+
+ // #2811: a screen reader must find the chevron and hear its state.
+ it("names the chevron and exposes the open state to assistive technology", async () => {
+ mount(withChildren());
+ const button = page.getByRole("button", {
+ name: "Expand or collapse",
+ expanded: false,
+ });
+ await expect.element(button).toBeInTheDocument();
+ expect(toggleButton("t")!.querySelector("svg")!.ariaHidden).toBe("true");
+
+ await open("t");
+ await expect
+ .element(
+ page.getByRole("button", {
+ name: "Expand or collapse",
+ expanded: true,
+ }),
+ )
+ .toBeInTheDocument();
+ });
+
+ it("keeps the open state for the block when the editor is recreated", async () => {
+ mount(withChildren());
+ await open("t");
+
+ remount(withChildren());
+ expect(isOpen("t")).toBe(true);
+ expect(childrenAreVisible("t")).toBe(true);
+
+ await open("t");
+ remount(withChildren());
+ expect(isOpen("t")).toBe(false);
+ });
+
+ it("keeps the caret where it is when the chevron is clicked", async () => {
+ mount(withChildren());
+ editor.setTextCursorPosition("after", "end");
+ editor.focus();
+
+ await open("t");
+
+ expect(editor.getTextCursorPosition().block.id).toBe("after");
+ expect(editor.isFocused()).toBe(true);
+ });
+
+ it("does not re-render the block when it is opened or closed", async () => {
+ mount(withChildren());
+ const content = own("t", ".bn-block-content")!;
+
+ await open("t");
+ await open("t");
+
+ expect(content.isConnected).toBe(true);
+ });
+
+ it("opens when a block is indented into it", async () => {
+ mount(withChildren());
+
+ await press("{Tab}", { block: "after", placement: "start" });
+
+ expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([
+ "c1",
+ "c2",
+ "after",
+ ]);
+ expect(isOpen("t")).toBe(true);
+ expect(childrenAreVisible("t")).toBe(true);
+
+ // The toggle opened itself, and that is kept like a click.
+ remount(editor.document);
+ expect(isOpen("t")).toBe(true);
+ });
+
+ // Notion keeps the toggle open, showing its empty-toggle placeholder.
+ it("stays open, as an empty toggle, when its last child is removed", async () => {
+ mount([toggle("t", "Title", [{ id: "c1", type: "paragraph" }])]);
+ await open("t");
+
+ editor.removeBlocks(["c1"]);
+
+ expect(editor.getBlock("t")!.children).toHaveLength(0);
+ expect(isOpen("t")).toBe(true);
+ expect(addBlockButton("t")).not.toBeNull();
+ });
+
+ it("stays open when one of several children is removed", async () => {
+ mount(withChildren());
+ await open("t");
+
+ editor.removeBlocks(["c1"]);
+
+ expect(isOpen("t")).toBe(true);
+ expect(childrenAreVisible("t")).toBe(true);
+ });
+ });
+
+ describe("layout", () => {
+ // The children span the frame below the title. When they fell into the
+ // chevron's grid column instead, they widened it and pushed the title far
+ // to the right.
+ it("keeps the title next to the chevron and the children below it", async () => {
+ mount(withChildren());
+ await open("t");
+
+ const frame = own("t", ".bn-toggle-frame")!.getBoundingClientRect();
+ const title = own("t", ".bn-inline-content")!.getBoundingClientRect();
+ const group = own("t", ".bn-block-group")!.getBoundingClientRect();
+ expect(title.left - frame.left).toBeLessThan(40);
+ expect(group.top).toBeGreaterThanOrEqual(title.bottom - 1);
+ expect(group.left).toBeLessThanOrEqual(title.left);
+ });
+ });
+
+ describe("empty toggle", () => {
+ it('shows an "Add block" button when open, which adds a child and puts the caret in it', async () => {
+ mount([toggle("t", "Title")]);
+ expect(addBlockButton("t")).toBeNull();
+
+ await open("t");
+ await userEvent.click(addBlockButton("t")!);
+
+ const child = editor.getBlock("t")!.children;
+ expect(child).toHaveLength(1);
+ expect(editor.getTextCursorPosition().block.id).toBe(child[0].id);
+ expect(addBlockButton("t")).toBeNull();
+ });
+
+ it('removes the "Add block" button when closed', async () => {
+ mount([toggle("t", "Title")]);
+ await open("t");
+
+ await open("t");
+
+ expect(addBlockButton("t")).toBeNull();
+ });
+
+ it('replaces the "Add block" button when a block is indented into it', async () => {
+ mount([
+ toggle("t", "Title"),
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+ await open("t");
+
+ await press("{Tab}", { block: "after", placement: "start" });
+
+ expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([
+ "after",
+ ]);
+ expect(addBlockButton("t")).toBeNull();
+ expect(childrenAreVisible("t")).toBe(true);
+ });
+
+ it('shows the "Add block" button when it was left open and is recreated', async () => {
+ mount([toggle("t", "Title")]);
+ await open("t");
+
+ remount([toggle("t", "Title")]);
+
+ expect(isOpen("t")).toBe(true);
+ expect(addBlockButton("t")).not.toBeNull();
+ });
+
+ it('shows no "Add block" button in a read-only editor', async () => {
+ mount([toggle("t", "Title")]);
+ editor.isEditable = false;
+
+ await open("t");
+
+ expect(addBlockButton("t")).toBeNull();
+ });
+
+ it('shows no "Add block" button when a read-only editor opens it from its saved state', async () => {
+ mount([toggle("t", "Title")]);
+ await open("t");
+
+ remount([toggle("t", "Title")], { editable: false });
+
+ expect(isOpen("t")).toBe(true);
+ expect(addBlockButton("t")).toBeNull();
+ });
+
+ // BLO-956 (comment): in an open, empty toggle, the caret could not move
+ // down out of the title.
+ it("ArrowDown moves the caret to the next block (BLO-956)", async () => {
+ mount([
+ toggle("t", "Title"),
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+ await open("t");
+
+ await press("{ArrowDown}", { block: "t", placement: "end" });
+
+ expect(editor.getTextCursorPosition().block.id).toBe("after");
+ });
+ });
+
+ describe("Enter", () => {
+ // BLO-929: Enter at the end of an open toggle's title should start the
+ // toggle's body, as in Notion, not a new block after the toggle.
+ it("at the end of an open toggle's title adds a first child (BLO-929)", async () => {
+ mount(withChildren());
+ await open("t");
+
+ await press("{Enter}", { block: "t", placement: "end" });
+
+ const toggleBlock = editor.getBlock("t")!;
+ expect(toggleBlock.children).toHaveLength(3);
+ expect(editor.getTextCursorPosition().block.id).toBe(
+ toggleBlock.children[0].id,
+ );
+ });
+
+ // BLO-998 / #2020: on a closed toggle, the new block after it must not
+ // take the toggle's children.
+ it("at the end of a closed toggle's title keeps the children in the toggle (BLO-998)", async () => {
+ mount(withChildren());
+
+ await press("{Enter}", { block: "t", placement: "end" });
+
+ const [first, second] = editor.document;
+ expect(first.id).toBe("t");
+ expect(first.children.map((child) => child.id)).toEqual(["c1", "c2"]);
+ expect(second.children).toHaveLength(0);
+ // A toggle list continues as a list; a heading continues with text.
+ expect(second.type).toBe(newBlockTypeAfterClosedToggle);
+ expect(editor.getTextCursorPosition().block.id).toBe(second.id);
+ });
+
+ // BLO-949: Enter in the title must not break the children apart. As in
+ // Notion, the text after the caret becomes the toggle's first child.
+ it("in the middle of an open toggle's title moves the rest of the title into a first child (BLO-949)", async () => {
+ mount(withChildren());
+ await open("t");
+
+ setCaretAt("t", 2);
+ await userEvent.keyboard("{Enter}");
+
+ expect(shape([editor.getBlock("t")!])).toMatch(
+ /^\w+"Ti"\[paragraph"tle", paragraph"One", paragraph"Two"\]$/,
+ );
+ expect(editor.getTextCursorPosition().block.id).toBe(
+ editor.getBlock("t")!.children[0].id,
+ );
+ });
+
+ // On a closed toggle, Enter splits the title as for any block: the rest
+ // goes into a new block after the toggle, which continues the list for a
+ // toggle list item, and the children stay with the toggle.
+ it("in the middle of a closed toggle's title moves the rest into a new block after it", async () => {
+ mount(withChildren());
+
+ setCaretAt("t", 2);
+ await userEvent.keyboard("{Enter}");
+
+ const [first, second] = editor.document;
+ expect(shape([first])).toMatch(
+ /^\w+"Ti"\[paragraph"One", paragraph"Two"\]$/,
+ );
+ expect(second.type).toBe(newBlockTypeAfterClosedToggle);
+ expect(shape([second])).toMatch(/"tle"$/);
+ });
+
+ // As in Notion, Enter on an empty last child stays inside the toggle.
+ it("on an empty last child adds another child", async () => {
+ mount([
+ toggle("t", "Title", [
+ { id: "c1", type: "paragraph", content: "One" },
+ { id: "c2", type: "paragraph" },
+ ]),
+ ]);
+ await open("t");
+
+ await press("{Enter}", { block: "c2", placement: "start" });
+
+ const children = editor.getBlock("t")!.children;
+ expect(children.map((child) => child.id).slice(0, 2)).toEqual([
+ "c1",
+ "c2",
+ ]);
+ expect(children).toHaveLength(3);
+ expect(editor.getTextCursorPosition().block.id).toBe(children[2].id);
+ });
+
+ // BLO-949: Enter in a child continues inside the toggle.
+ it("at the end of a child adds a sibling child (BLO-949)", async () => {
+ mount(withChildren());
+ await open("t");
+
+ await press("{Enter}", { block: "c1", placement: "end" });
+
+ const children = editor.getBlock("t")!.children;
+ expect(children).toHaveLength(3);
+ expect(children[0].id).toBe("c1");
+ expect(editor.getTextCursorPosition().block.id).toBe(children[1].id);
+ });
+ });
+
+ it("turned into a paragraph shows its children and no chevron", () => {
+ mount(withChildren());
+
+ editor.updateBlock("t", { type: "paragraph", props: {} });
+
+ expect(toggleButton("t")).toBeNull();
+ expect(childrenAreVisible("t")).toBe(true);
+ });
+
+ describe("Backspace", () => {
+ // As in Notion, Backspace at the start of the first child merges it into
+ // the title.
+ it("at the start of the first child merges it into the title", async () => {
+ mount(withChildren());
+ await open("t");
+
+ await press("{Backspace}", { block: "c1", placement: "start" });
+
+ expect(shape([editor.getBlock("t")!])).toMatch(
+ /^\w+"TitleOne"\[paragraph"Two"\]$/,
+ );
+ expect(editor.document.map((block) => block.id)).toEqual(["t", "after"]);
+ });
+ });
+
+ // Shift-Mod-ArrowUp/Down, BlockNote's shortcuts for moving blocks.
+ describe("moving blocks", () => {
+ const moveUp = `{${MOD}>}{Shift>}{ArrowUp}{/Shift}{/${MOD}}`;
+ const moveDown = `{${MOD}>}{Shift>}{ArrowDown}{/Shift}{/${MOD}}`;
+
+ it("opens a closed toggle when a block is moved into it", async () => {
+ mount(withChildren());
+
+ await press(moveUp, { block: "after", placement: "start" });
+
+ expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([
+ "c1",
+ "c2",
+ "after",
+ ]);
+ expect(isOpen("t")).toBe(true);
+ expect(childrenAreVisible("t")).toBe(true);
+ });
+
+ it("keeps a toggle open when its last child is moved out", async () => {
+ mount([
+ toggle("t", "Title", [{ id: "c1", type: "paragraph", content: "One" }]),
+ ]);
+ await open("t");
+
+ await press(moveDown, { block: "c1", placement: "start" });
+
+ expect(editor.document.map((block) => block.id)).toEqual(["t", "c1"]);
+ expect(isOpen("t")).toBe(true);
+ expect(addBlockButton("t")).not.toBeNull();
+ });
+
+ it("keeps an open toggle open, with its children, when it is moved", async () => {
+ mount([
+ { id: "before", type: "paragraph", content: "Before" },
+ ...withChildren(),
+ ]);
+ await open("t");
+
+ await press(moveUp, { block: "t", placement: "end" });
+
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "t",
+ "before",
+ "after",
+ ]);
+ expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([
+ "c1",
+ "c2",
+ ]);
+ expect(isOpen("t")).toBe(true);
+ expect(childrenAreVisible("t")).toBe(true);
+ });
+
+ it("keeps a closed toggle closed, with its children, when it is moved", async () => {
+ mount([
+ { id: "before", type: "paragraph", content: "Before" },
+ ...withChildren(),
+ ]);
+
+ await press(moveUp, { block: "t", placement: "end" });
+
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "t",
+ "before",
+ "after",
+ ]);
+ expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([
+ "c1",
+ "c2",
+ ]);
+ expect(isOpen("t")).toBe(false);
+ expect(childrenAreVisible("t")).toBe(false);
+ });
+ });
+
+ // A block dragged onto a toggle becomes its first child, as in Notion. For
+ // an empty toggle, this is the only way to drop a block into it (BLO-956).
+ describe("drop onto the toggle", () => {
+ it("onto the title: shows the place above the first child, and drops it there", async () => {
+ mount(withChildren());
+ await open("t");
+
+ const drag = dragBlockOver("after", own("t", ".bn-inline-content")!);
+
+ const cursor = dropCursor()!.getBoundingClientRect();
+ expect(
+ Math.abs(cursor.top - blockElement("c1").getBoundingClientRect().top),
+ ).toBeLessThan(6);
+ expect(highlightedDropTarget()).toBe("t");
+
+ drag.drop();
+
+ expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([
+ "after",
+ "c1",
+ "c2",
+ ]);
+ await expect.poll(dropCursor).toBeNull();
+ expect(highlightedDropTarget()).toBeNull();
+ });
+
+ it("onto the chevron of a closed toggle: drops it as the first child, and opens the toggle", async () => {
+ mount(withChildren());
+
+ dragBlockOver("after", toggleButton("t")!).drop();
+
+ expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([
+ "after",
+ "c1",
+ "c2",
+ ]);
+ expect(isOpen("t")).toBe(true);
+ });
+
+ it("onto 'Add block' of an empty toggle: shows the place below the title, and drops it there (BLO-956)", async () => {
+ mount([
+ toggle("t", "Title"),
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+ await open("t");
+
+ const drag = dragBlockOver("after", addBlockButton("t")!);
+
+ const cursor = dropCursor()!.getBoundingClientRect();
+ const title = own("t", ".bn-block-content")!.getBoundingClientRect();
+ expect(Math.abs(cursor.top - title.bottom)).toBeLessThan(6);
+ expect(highlightedDropTarget()).toBe("t");
+
+ drag.drop();
+
+ expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([
+ "after",
+ ]);
+ });
+
+ it("drops it as usual when the toggle is removed during the drag", async () => {
+ mount(withChildren());
+ await open("t");
+
+ const drag = dragBlockOver("after", own("t", ".bn-inline-content")!);
+ expect(highlightedDropTarget()).toBe("t");
+ // E.g. a collaborator removes the toggle.
+ editor.removeBlocks(["t"]);
+ drag.drop();
+
+ expect(editor.getBlock("t")).toBeUndefined();
+ expect(editor.getBlock("after")).toBeDefined();
+ expect(highlightedDropTarget()).toBeNull();
+ });
+
+ it("onto a child: drops it between the children, as usual", async () => {
+ mount(withChildren());
+ await open("t");
+
+ const drag = dragBlockOver("after", own("c2", ".bn-inline-content")!);
+ expect(highlightedDropTarget()).toBeNull();
+ drag.drop();
+
+ const children = editor.getBlock("t")!.children.map((child) => child.id);
+ expect(children).toHaveLength(3);
+ expect(children[0]).toBe("c1");
+ });
+ });
+
+ describe("indentation", () => {
+ it("Shift-Tab moves a child out of the toggle", async () => {
+ mount(withChildren());
+ await open("t");
+
+ await press("{Shift>}{Tab}{/Shift}", { block: "c2", placement: "start" });
+
+ expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([
+ "c1",
+ ]);
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "t",
+ "c2",
+ "after",
+ ]);
+ });
+ });
+});
+
+// As in Notion, Enter in an empty toggle title removes the toggle: a toggle
+// list item becomes a paragraph, a toggle heading a regular heading.
+describe("Enter in an empty toggle title", () => {
+ it("turns a toggle list item into a paragraph", async () => {
+ mount([
+ { id: "t", type: "toggleListItem", content: "Title" },
+ { id: "empty", type: "toggleListItem" },
+ ]);
+
+ await press("{Enter}", { block: "empty", placement: "start" });
+
+ expect(editor.getBlock("empty")!.type).toBe("paragraph");
+ expect(editor.document).toHaveLength(2);
+ });
+
+ // The same when the toggle is open: an empty title never starts the
+ // toggle's children.
+ it("turns an open toggle list item into a paragraph", async () => {
+ mount([
+ { id: "t", type: "paragraph", content: "Before" },
+ { id: "empty", type: "toggleListItem" },
+ ]);
+ await userEvent.click(toggleButton("empty")!);
+
+ await press("{Enter}", { block: "empty", placement: "start" });
+
+ expect(editor.getBlock("empty")!.type).toBe("paragraph");
+ expect(editor.getBlock("empty")!.children).toHaveLength(0);
+ expect(editor.document).toHaveLength(2);
+ });
+
+ it("turns an open toggle heading into a regular heading", async () => {
+ mount([
+ { id: "t", type: "paragraph", content: "Before" },
+ { id: "empty", type: "heading", props: { level: 2, isToggleable: true } },
+ ]);
+ await userEvent.click(toggleButton("empty")!);
+
+ await press("{Enter}", { block: "empty", placement: "start" });
+
+ expect(editor.getBlock("empty")!.props).toMatchObject({
+ level: 2,
+ isToggleable: false,
+ });
+ expect(editor.getBlock("empty")!.children).toHaveLength(0);
+ expect(editor.document).toHaveLength(2);
+ });
+
+ // As in Notion, the children stay nested under the reset block.
+ for (const state of ["closed", "open"] as const) {
+ it(`turns a toggle list item with children into a paragraph, keeping the children (${state})`, async () => {
+ mount([
+ { id: "t", type: "paragraph", content: "Before" },
+ {
+ id: "empty",
+ type: "toggleListItem",
+ children: [{ id: "c1", type: "paragraph", content: "One" }],
+ },
+ ]);
+ if (state === "open") {
+ await userEvent.click(toggleButton("empty")!);
+ }
+
+ await press("{Enter}", { block: "empty", placement: "start" });
+
+ expect(shape()).toBe('paragraph"Before", paragraph""[paragraph"One"]');
+ });
+
+ it(`turns a toggle heading with children into a regular heading, keeping the children (${state})`, async () => {
+ mount([
+ { id: "t", type: "paragraph", content: "Before" },
+ {
+ id: "empty",
+ type: "heading",
+ props: { level: 2, isToggleable: true },
+ children: [{ id: "c1", type: "paragraph", content: "One" }],
+ },
+ ]);
+ if (state === "open") {
+ await userEvent.click(toggleButton("empty")!);
+ }
+
+ await press("{Enter}", { block: "empty", placement: "start" });
+
+ expect(editor.getBlock("empty")!.props).toMatchObject({
+ level: 2,
+ isToggleable: false,
+ });
+ expect(shape()).toBe('paragraph"Before", heading""[paragraph"One"]');
+ });
+ }
+
+ it("turns a nested toggle list item into a paragraph, which stays nested", async () => {
+ mount([
+ {
+ id: "parent",
+ type: "paragraph",
+ content: "Parent",
+ children: [{ id: "empty", type: "toggleListItem" }],
+ },
+ ]);
+
+ await press("{Enter}", { block: "empty", placement: "start" });
+
+ expect(editor.getBlock("empty")!.type).toBe("paragraph");
+ expect(editor.getParentBlock("empty")!.id).toBe("parent");
+ });
+
+ it("turns a toggle heading into a regular heading", async () => {
+ mount([
+ { id: "t", type: "paragraph", content: "Before" },
+ { id: "empty", type: "heading", props: { level: 2, isToggleable: true } },
+ ]);
+
+ await press("{Enter}", { block: "empty", placement: "start" });
+
+ expect(editor.getBlock("empty")!.props).toMatchObject({
+ level: 2,
+ isToggleable: false,
+ });
+ expect(editor.document).toHaveLength(2);
+ expect(toggleButton("empty")).toBeNull();
+ });
+});
+
+// As in Notion, Backspace at the start of a non-empty toggle heading turns it
+// into a regular heading. The text and (unlike Notion) the children stay.
+describe("Backspace at the start of a non-empty toggle title", () => {
+ it("turns a toggle heading into a regular heading", async () => {
+ mount([
+ {
+ id: "t",
+ type: "heading",
+ props: { level: 2, isToggleable: true },
+ content: "Title",
+ children: [{ id: "c1", type: "paragraph", content: "One" }],
+ },
+ ]);
+
+ await press("{Backspace}", { block: "t", placement: "start" });
+
+ const block = editor.getBlock("t")!;
+ expect(block.type).toBe("heading");
+ expect(block.props).toMatchObject({ level: 2, isToggleable: false });
+ expect(shape([block])).toBe('heading"Title"[paragraph"One"]');
+ });
+
+ // Not compared with Notion: current behaviour, as for the other lists.
+ it("turns a toggle list item into a paragraph", async () => {
+ mount([
+ {
+ id: "t",
+ type: "toggleListItem",
+ content: "Title",
+ children: [{ id: "c1", type: "paragraph", content: "One" }],
+ },
+ ]);
+
+ await press("{Backspace}", { block: "t", placement: "start" });
+
+ expect(shape([editor.getBlock("t")!])).toBe(
+ 'paragraph"Title"[paragraph"One"]',
+ );
+ });
+});
+
+// As in Notion, Enter at the start of a non-empty toggle list item inserts an
+// empty toggle list item above it. The item keeps its text and children, open
+// or closed, and the caret stays at its start.
+describe("Enter at the start of a non-empty toggle list item", () => {
+ for (const state of ["closed", "open"] as const) {
+ it(`inserts an empty toggle list item above it (${state})`, async () => {
+ mount([
+ {
+ id: "t",
+ type: "toggleListItem",
+ content: "Title",
+ children: [{ id: "c1", type: "paragraph", content: "One" }],
+ },
+ ]);
+ if (state === "open") {
+ await userEvent.click(toggleButton("t")!);
+ }
+
+ await press("{Enter}", { block: "t", placement: "start" });
+
+ expect(shape()).toBe(
+ 'toggleListItem"", toggleListItem"Title"[paragraph"One"]',
+ );
+ const item = editor.document[1];
+ expect(editor.getTextCursorPosition().block.id).toBe(item.id);
+ expect(isOpen(item.id)).toBe(state === "open");
+ });
+ }
+});
+
+// A toggle heading's frame puts its content one level deeper, so the heading
+// rules in Block.css need their own selector for it.
+describe("toggle heading appearance", () => {
+ it.each([1, 2, 3] as const)(
+ "has the size and weight of a regular heading at level %d",
+ (level) => {
+ mount([
+ { id: "h", type: "heading", props: { level }, content: "Heading" },
+ {
+ id: "t",
+ type: "heading",
+ props: { level, isToggleable: true },
+ content: "Toggle",
+ },
+ ]);
+
+ const style = (id: string) =>
+ getComputedStyle(own(id, ".bn-block-content")!);
+ expect(style("t").fontSize).toBe(style("h").fontSize);
+ expect(style("t").fontWeight).toBe(style("h").fontWeight);
+ },
+ );
+});
+
+// BLO-959: turning a toggle heading into a regular heading must remove the
+// toggle behaviour. Each way of turning a block into a heading is covered.
+// Unlike Notion, which moves the children out (its headings can't have
+// children), the children stay nested under the heading.
+describe("toggle heading turned into a regular heading (BLO-959)", () => {
+ const toggleHeading = () => [
+ {
+ id: "t",
+ type: "heading" as const,
+ props: { level: 1 as const, isToggleable: true },
+ content: "Title",
+ children: [{ id: "c1", type: "paragraph" as const, content: "One" }],
+ },
+ ];
+
+ function expectRegularHeading() {
+ expect(editor.getBlock("t")!.props).toMatchObject({
+ level: 2,
+ isToggleable: false,
+ });
+ expect(toggleButton("t")).toBeNull();
+ expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([
+ "c1",
+ ]);
+ expect(childrenAreVisible("t")).toBe(true);
+ }
+
+ it("with the block type menu's props", () => {
+ mount(toggleHeading());
+
+ // What the formatting toolbar's block type select applies for "Heading 2".
+ editor.updateBlock("t", {
+ type: "heading",
+ props: { level: 2, isToggleable: false },
+ });
+
+ expectRegularHeading();
+ });
+
+ // Not compared with Notion: its Cmd-Option-2 could not be automated there.
+ it("with the heading keyboard shortcut", async () => {
+ mount(toggleHeading());
+
+ await press(`{${MOD}>}{Alt>}2{/Alt}{/${MOD}}`, {
+ block: "t",
+ placement: "end",
+ });
+
+ expectRegularHeading();
+ });
+
+ it("with the markdown shortcut", async () => {
+ mount(toggleHeading());
+
+ await press("## ", { block: "t", placement: "start" });
+
+ expectRegularHeading();
+ expect(editor.getBlock("t")!.content).toEqual([
+ { type: "text", text: "Title", styles: {} },
+ ]);
+ });
+
+ // The slash menu updates an empty block in place, as the block type menu
+ // does. Notion differs here: its "Heading 2" in an empty toggle heading
+ // keeps the toggle heading and inserts a regular heading after it.
+ it("with the slash menu's heading item in an empty toggle heading", () => {
+ mount([
+ {
+ id: "t",
+ type: "heading",
+ props: { level: 1, isToggleable: true },
+ children: [{ id: "c1", type: "paragraph", content: "One" }],
+ },
+ ]);
+ editor.setTextCursorPosition("t", "end");
+
+ getDefaultSlashMenuItems(editor)
+ .find((item) => item.key === "heading_2")!
+ .onItemClick();
+
+ expectRegularHeading();
+ expect(editor.getTextCursorPosition().block.id).toBe("t");
+ });
+});
diff --git a/packages/core/src/blocks/defaultBlockTypeItems.test.ts b/packages/core/src/blocks/defaultBlockTypeItems.test.ts
new file mode 100644
index 0000000000..97a4f23c93
--- /dev/null
+++ b/packages/core/src/blocks/defaultBlockTypeItems.test.ts
@@ -0,0 +1,70 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from "vite-plus/test";
+
+import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
+import { BlockNoteSchema } from "./BlockNoteSchema.js";
+import { createHeadingBlockSpec } from "./Heading/block.js";
+import { defaultBlockSpecs } from "./defaultBlocks.js";
+import { getDefaultBlockTypeItems } from "./defaultBlockTypeItems.js";
+
+/** The items for a schema whose heading is `heading`. */
+function items(heading = createHeadingBlockSpec()) {
+ const schema = BlockNoteSchema.create({
+ blockSpecs: { ...defaultBlockSpecs, heading },
+ });
+ // Only the schema is read.
+ return getDefaultBlockTypeItems({ schema } as unknown as BlockNoteEditor);
+}
+
+describe("getDefaultBlockTypeItems", () => {
+ it("offers every default block type, in the block type select's order", () => {
+ expect(items().map((item) => item.key)).toEqual([
+ "paragraph",
+ "heading",
+ "heading_2",
+ "heading_3",
+ "heading_4",
+ "heading_5",
+ "heading_6",
+ "toggle_heading",
+ "toggle_heading_2",
+ "toggle_heading_3",
+ "quote",
+ "toggle_list",
+ "bullet_list",
+ "numbered_list",
+ "check_list",
+ ]);
+ });
+
+ it("makes a regular heading turn a toggle heading into a regular one (BLO-959)", () => {
+ expect(items().find((item) => item.key === "heading_2")?.props).toEqual({
+ level: 2,
+ isToggleable: false,
+ });
+ });
+
+ it("offers regular headings when toggle headings are disabled (BLO-1236)", () => {
+ const offered = items(
+ createHeadingBlockSpec({ allowToggleHeadings: false }),
+ );
+ expect(offered.filter((item) => item.type === "heading")).toEqual([
+ { key: "heading", type: "heading", props: { level: 1 } },
+ { key: "heading_2", type: "heading", props: { level: 2 } },
+ { key: "heading_3", type: "heading", props: { level: 3 } },
+ { key: "heading_4", type: "heading", props: { level: 4 } },
+ { key: "heading_5", type: "heading", props: { level: 5 } },
+ { key: "heading_6", type: "heading", props: { level: 6 } },
+ ]);
+ });
+
+ it("offers only the configured heading levels (BLO-990)", () => {
+ expect(
+ items(createHeadingBlockSpec({ levels: [2, 4] }))
+ .filter((item) => item.type === "heading")
+ .map((item) => item.key),
+ ).toEqual(["heading_2", "heading_4", "toggle_heading_2"]);
+ });
+});
diff --git a/packages/core/src/blocks/defaultBlockTypeItems.ts b/packages/core/src/blocks/defaultBlockTypeItems.ts
new file mode 100644
index 0000000000..6d5e1b3996
--- /dev/null
+++ b/packages/core/src/blocks/defaultBlockTypeItems.ts
@@ -0,0 +1,99 @@
+import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
+import { editorHasBlockWithType } from "./defaultBlockTypeGuards.js";
+
+const HEADING_KEYS = {
+ 1: "heading",
+ 2: "heading_2",
+ 3: "heading_3",
+ 4: "heading_4",
+ 5: "heading_5",
+ 6: "heading_6",
+} as const;
+
+const TOGGLE_HEADING_KEYS = {
+ 1: "toggle_heading",
+ 2: "toggle_heading_2",
+ 3: "toggle_heading_3",
+} as const;
+
+/**
+ * A block type that the default menus (the slash menu and the block type
+ * select) offer, with the props that choosing it sets.
+ */
+export type DefaultBlockTypeItem = {
+ /** The item's key in `dictionary.slash_menu`, which both menus share. */
+ key:
+ | (typeof HEADING_KEYS)[keyof typeof HEADING_KEYS]
+ | (typeof TOGGLE_HEADING_KEYS)[keyof typeof TOGGLE_HEADING_KEYS]
+ | "paragraph"
+ | "quote"
+ | "toggle_list"
+ | "bullet_list"
+ | "numbered_list"
+ | "check_list";
+ type: string;
+ props?: Record;
+};
+
+/**
+ * The block types that the editor's schema supports, out of the ones that the
+ * default menus offer: paragraph, headings, toggle headings, quote and lists.
+ * Each menu places them in its own order, by key; this list has the block type
+ * select's order.
+ *
+ * Headings are offered for the heading's configured `levels` (BLO-990).
+ * Toggle headings are offered only when the heading has `isToggleable`, and a
+ * regular heading then sets `isToggleable: false`, so that choosing it turns a
+ * toggle heading into a regular one (BLO-959, BLO-1236).
+ */
+export function getDefaultBlockTypeItems(
+ editor: BlockNoteEditor,
+): DefaultBlockTypeItem[] {
+ const items: DefaultBlockTypeItem[] = [];
+
+ if (editorHasBlockWithType(editor, "paragraph")) {
+ items.push({ key: "paragraph", type: "paragraph" });
+ }
+
+ if (editorHasBlockWithType(editor, "heading", { level: "number" })) {
+ const levels = editor.schema.blockSchema.heading.propSchema.level.values;
+ const hasToggles = editorHasBlockWithType(editor, "heading", {
+ level: "number",
+ isToggleable: "boolean",
+ });
+ for (const level of [1, 2, 3, 4, 5, 6] as const) {
+ if (levels?.includes(level)) {
+ items.push({
+ key: HEADING_KEYS[level],
+ type: "heading",
+ props: hasToggles ? { level, isToggleable: false } : { level },
+ });
+ }
+ }
+ if (hasToggles) {
+ for (const level of [1, 2, 3] as const) {
+ if (levels?.includes(level)) {
+ items.push({
+ key: TOGGLE_HEADING_KEYS[level],
+ type: "heading",
+ props: { level, isToggleable: true },
+ });
+ }
+ }
+ }
+ }
+
+ for (const [key, type] of [
+ ["quote", "quote"],
+ ["toggle_list", "toggleListItem"],
+ ["bullet_list", "bulletListItem"],
+ ["numbered_list", "numberedListItem"],
+ ["check_list", "checkListItem"],
+ ] as const) {
+ if (editorHasBlockWithType(editor, type)) {
+ items.push({ key, type });
+ }
+ }
+
+ return items;
+}
diff --git a/packages/core/src/blocks/index.ts b/packages/core/src/blocks/index.ts
index 76fc76d8a7..72cfa6e7a5 100644
--- a/packages/core/src/blocks/index.ts
+++ b/packages/core/src/blocks/index.ts
@@ -19,11 +19,12 @@ export { EMPTY_CELL_HEIGHT, EMPTY_CELL_WIDTH } from "./Table/TableExtension.js";
export * from "./Code/helpers/parse/parsePreCode.js";
export * from "./Code/helpers/render/createCodeBlock.js";
export * from "./Code/helpers/toExternalHTML/createPreCode.js";
-export * from "./ToggleWrapper/createToggleWrapper.js";
+export * from "./ToggleWrapper/createToggleFrame.js";
export * from "./PageBreak/getPageBreakSlashMenuItems.js";
export * from "./BlockNoteSchema.js";
export * from "./defaultBlockHelpers.js";
export * from "./defaultBlocks.js";
export * from "./defaultBlockTypeGuards.js";
+export * from "./defaultBlockTypeItems.js";
export * from "./defaultProps.js";
diff --git a/packages/core/src/blocks/utils/listItemEnterHandler.ts b/packages/core/src/blocks/utils/listItemEnterHandler.ts
deleted file mode 100644
index 6008c1a023..0000000000
--- a/packages/core/src/blocks/utils/listItemEnterHandler.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { splitBlockTr } from "../../api/blockManipulation/commands/splitBlock/splitBlock.js";
-import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
-import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js";
-import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
-
-export const handleEnter = (
- editor: BlockNoteEditor,
- listItemType: string,
-) => {
- const { blockInfo, selectionEmpty } = editor.transact((tr) => {
- return {
- blockInfo: getBlockInfoFromSelection(tr),
- selectionEmpty: tr.selection.anchor === tr.selection.head,
- };
- });
-
- if (!blockInfo.hasContent) {
- return false;
- }
- const { block: blockContainer, content } = blockInfo;
-
- if (!(content.node.type.name === listItemType) || !selectionEmpty) {
- return false;
- }
-
- if (blockInfo.isContentEmpty) {
- editor.transact((tr) => {
- updateBlockTr(tr, blockContainer.beforePos, {
- type: "paragraph",
- props: {},
- });
- });
- return true;
- } else if (content.node.childCount > 0) {
- return editor.transact((tr) => {
- tr.deleteSelection();
- tr.scrollIntoView();
- return splitBlockTr(tr, tr.selection.from, true);
- });
- }
-
- return false;
-};
diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css
index d18a3ffb76..d12187f19a 100644
--- a/packages/core/src/editor/Block.css
+++ b/packages/core/src/editor/Block.css
@@ -25,7 +25,9 @@ BASIC STYLES
.ProseMirror-selectednode > .bn-block-content > *,
/* Case for blocks/inline content where class is set manually */
.bn-block-content .ProseMirror-selectednode,
-.bn-inline-content .ProseMirror-selectednode {
+.bn-inline-content .ProseMirror-selectednode,
+/* The block a dragged block drops into (set by the drop cursor) */
+.bn-block-outer[data-drop-target="true"] {
/* Anchor for the `::after` highlight overlay below. */
position: relative;
}
@@ -35,7 +37,8 @@ BASIC STYLES
.bn-block-content.ProseMirror-selectednode > *::after,
.ProseMirror-selectednode > .bn-block-content > *::after,
.bn-block-content .ProseMirror-selectednode::after,
-.bn-inline-content .ProseMirror-selectednode::after {
+.bn-inline-content .ProseMirror-selectednode::after,
+.bn-block-outer[data-drop-target="true"]::after {
content: "";
position: absolute;
inset: 0;
@@ -194,7 +197,12 @@ NESTED BLOCKS
--prev-level: 0.8em;
}
-.bn-block-outer[data-prev-type="heading"] > .bn-block > .bn-block-content {
+.bn-block-outer[data-prev-type="heading"] > .bn-block > .bn-block-content,
+.bn-block-outer[data-prev-type="heading"]
+ > .bn-block
+ > .bn-toggle-frame
+ > .bn-toggle-slot
+ > .bn-block-content {
font-size: var(--prev-level);
font-weight: bold;
}
@@ -202,6 +210,12 @@ NESTED BLOCKS
.bn-block-outer:not([data-prev-type])
> .bn-block
> .bn-block-content[data-content-type="heading"],
+/* A toggle heading, inside its toggle frame. */
+.bn-block-outer:not([data-prev-type])
+ > .bn-block
+ > .bn-toggle-frame
+ > .bn-toggle-slot
+ > .bn-block-content[data-content-type="heading"],
.bn-block-outer:not([data-prev-type])
> .bn-block
> div[data-type="modification"]
@@ -312,25 +326,6 @@ NESTED BLOCKS
}
/* Toggle */
-.bn-block:has(
- > .bn-block-content > div > .bn-toggle-wrapper[data-show-children="false"]
- )
- > .bn-block-group,
-.bn-block:has(
- > .react-renderer
- > .bn-block-content
- > div
- > .bn-toggle-wrapper[data-show-children="false"]
- )
- > .bn-block-group {
- display: none;
-}
-
-.bn-toggle-wrapper {
- display: flex;
- align-items: center;
-}
-
.bn-toggle-button {
color: var(--bn-colors-editor-text);
padding: 3px;
@@ -341,10 +336,47 @@ NESTED BLOCKS
height: 18px;
}
-.bn-toggle-wrapper[data-show-children="true"] .bn-toggle-button {
+.bn-toggle-button[aria-expanded="true"] {
transform: rotate(90deg);
}
+/* The frame of the built-in toggle blocks (see `createToggleFrame`): the
+ chevron, then the block's content, and the children below, across both
+ columns. The slot is `display: contents`, so the content and the child
+ group BlockNote mounts in it are items of this grid. */
+.bn-toggle-frame {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+}
+
+.bn-toggle-slot {
+ display: contents;
+}
+
+.bn-toggle-slot > .bn-block-group,
+.bn-toggle-frame > .bn-toggle-add-block-button {
+ grid-column: 1 / -1;
+}
+
+.bn-toggle-frame:has(> .bn-toggle-button[aria-expanded="false"])
+ > .bn-toggle-slot
+ > .bn-block-group {
+ display: none;
+}
+
+/* A heading's top spacing goes on the frame, so the chevron stays centred on
+ the heading text. */
+.bn-toggle-frame:has(
+ > .bn-toggle-slot > .bn-block-content[data-content-type="heading"]
+) {
+ padding-top: 18px;
+}
+
+.bn-toggle-slot > .bn-block-content[data-content-type="heading"] {
+ padding-top: 0;
+}
+
.bn-toggle-add-block-button {
font-size: 16px;
color: var(--bn-colors-side-menu);
@@ -875,112 +907,98 @@ NESTED BLOCKS
}
/* TEXT COLORS */
+/* A block's color also applies to its children: the block's element carries
+ its colors too (see `BlockContainer`), and they inherit or paint from there.
+ Its content can sit inside a frame at any depth, so no selector here walks
+ down to it. */
[data-style-type="textColor"][data-value="gray"],
-[data-text-color="gray"],
-.bn-block:has(> .bn-block-content[data-text-color="gray"]) {
+[data-text-color="gray"] {
color: #9b9a97;
}
[data-style-type="textColor"][data-value="brown"],
-[data-text-color="brown"],
-.bn-block:has(> .bn-block-content[data-text-color="brown"]) {
+[data-text-color="brown"] {
color: #64473a;
}
[data-style-type="textColor"][data-value="red"],
-[data-text-color="red"],
-.bn-block:has(> .bn-block-content[data-text-color="red"]) {
+[data-text-color="red"] {
color: #e03e3e;
}
[data-style-type="textColor"][data-value="orange"],
-[data-text-color="orange"],
-.bn-block:has(> .bn-block-content[data-text-color="orange"]) {
+[data-text-color="orange"] {
color: #d9730d;
}
[data-style-type="textColor"][data-value="yellow"],
-[data-text-color="yellow"],
-.bn-block:has(> .bn-block-content[data-text-color="yellow"]) {
+[data-text-color="yellow"] {
color: #dfab01;
}
[data-style-type="textColor"][data-value="green"],
-[data-text-color="green"],
-.bn-block:has(> .bn-block-content[data-text-color="green"]) {
+[data-text-color="green"] {
color: #4d6461;
}
[data-style-type="textColor"][data-value="blue"],
-[data-text-color="blue"],
-.bn-block:has(> .bn-block-content[data-text-color="blue"]) {
+[data-text-color="blue"] {
color: #0b6e99;
}
[data-style-type="textColor"][data-value="purple"],
-[data-text-color="purple"],
-.bn-block:has(> .bn-block-content[data-text-color="purple"]) {
+[data-text-color="purple"] {
color: #6940a5;
}
[data-style-type="textColor"][data-value="pink"],
-[data-text-color="pink"],
-.bn-block:has(> .bn-block-content[data-text-color="pink"]) {
+[data-text-color="pink"] {
color: #ad1a72;
}
/* BACKGROUND COLORS */
[data-style-type="backgroundColor"][data-value="gray"],
-[data-background-color="gray"],
-.bn-block:has(> .bn-block-content[data-background-color="gray"]) {
+[data-background-color="gray"] {
background-color: #ebeced;
}
[data-style-type="backgroundColor"][data-value="brown"],
-[data-background-color="brown"],
-.bn-block:has(> .bn-block-content[data-background-color="brown"]) {
+[data-background-color="brown"] {
background-color: #e9e5e3;
}
[data-style-type="backgroundColor"][data-value="red"],
-[data-background-color="red"],
-.bn-block:has(> .bn-block-content[data-background-color="red"]) {
+[data-background-color="red"] {
background-color: #fbe4e4;
}
[data-style-type="backgroundColor"][data-value="orange"],
-[data-background-color="orange"],
-.bn-block:has(> .bn-block-content[data-background-color="orange"]) {
+[data-background-color="orange"] {
background-color: #f6e9d9;
}
[data-style-type="backgroundColor"][data-value="yellow"],
-[data-background-color="yellow"],
-.bn-block:has(> .bn-block-content[data-background-color="yellow"]) {
+[data-background-color="yellow"] {
background-color: #fbf3db;
}
[data-style-type="backgroundColor"][data-value="green"],
-[data-background-color="green"],
-.bn-block:has(> .bn-block-content[data-background-color="green"]) {
+[data-background-color="green"] {
background-color: #ddedea;
}
[data-style-type="backgroundColor"][data-value="blue"],
-[data-background-color="blue"],
-.bn-block:has(> .bn-block-content[data-background-color="blue"]) {
+[data-background-color="blue"] {
background-color: #ddebf1;
}
[data-style-type="backgroundColor"][data-value="purple"],
-[data-background-color="purple"],
-.bn-block:has(> .bn-block-content[data-background-color="purple"]) {
+[data-background-color="purple"] {
background-color: #eae4f2;
}
[data-style-type="backgroundColor"][data-value="pink"],
-[data-background-color="pink"],
-.bn-block:has(> .bn-block-content[data-background-color="pink"]) {
+[data-background-color="pink"] {
background-color: #f4dfeb;
}
diff --git a/packages/core/src/editor/blockColors.browser.test.ts b/packages/core/src/editor/blockColors.browser.test.ts
new file mode 100644
index 0000000000..cb508b1360
--- /dev/null
+++ b/packages/core/src/editor/blockColors.browser.test.ts
@@ -0,0 +1,129 @@
+import { afterEach, describe, expect, it } from "vite-plus/test";
+
+import "../style.css";
+import { BlockNoteSchema } from "../blocks/BlockNoteSchema.js";
+import { defaultToggledState } from "../blocks/ToggleWrapper/createToggleFrame.js";
+import { defaultProps } from "../blocks/defaultProps.js";
+import { createBlockSpec } from "../schema/blocks/createSpec.js";
+import { BlockNoteEditor } from "./BlockNoteEditor.js";
+
+// A block's text and background color also apply to its child blocks. The
+// block's content may sit inside a frame (`renderFrame`), at any depth.
+
+const RED = "rgb(224, 62, 62)";
+const GREEN = "rgb(77, 100, 97)";
+const BLUE_BACKGROUND = "rgb(221, 235, 241)";
+
+// A custom block whose frame puts the content two levels down.
+const framedNote = createBlockSpec(
+ { type: "framedNote", propSchema: { ...defaultProps }, content: "inline" },
+ {
+ render() {
+ const dom = document.createElement("p");
+ return { dom, contentDOM: dom };
+ },
+ renderFrame() {
+ const dom = document.createElement("section");
+ const inner = document.createElement("div");
+ const slot = document.createElement("div");
+ inner.append(slot);
+ dom.append(inner);
+ return { dom, slot };
+ },
+ },
+)();
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: { framedNote },
+});
+
+let editor: BlockNoteEditor | undefined;
+let root: HTMLElement | undefined;
+
+afterEach(() => {
+ editor?._tiptapEditor.destroy();
+ root?.remove();
+ editor = undefined;
+ root = undefined;
+});
+
+function mount(type: string) {
+ // Toggles hide their children until opened.
+ defaultToggledState.set({ id: "parent" }, true);
+ root = document.createElement("div");
+ document.body.appendChild(root);
+ editor = BlockNoteEditor.create({
+ schema,
+ initialContent: [
+ {
+ id: "parent",
+ type,
+ props: { textColor: "red", backgroundColor: "blue" },
+ content: "Parent",
+ children: [{ id: "child", type: "paragraph", content: "Child" }],
+ },
+ ],
+ });
+ editor.mount(root);
+ return root;
+}
+
+function childContent(container: HTMLElement) {
+ const child = container.querySelector(
+ '[data-id="child"] .bn-inline-content',
+ );
+ if (!child) {
+ throw new Error("child block not rendered");
+ }
+ return child;
+}
+
+/** The background painted behind the child: the nearest one set on it or an ancestor. */
+function backgroundBehind(element: HTMLElement) {
+ for (let el: Element | null = element; el; el = el.parentElement) {
+ const background = getComputedStyle(el).backgroundColor;
+ if (background !== "rgba(0, 0, 0, 0)") {
+ return background;
+ }
+ }
+ return undefined;
+}
+
+describe.each(["paragraph", "toggleListItem", "framedNote"])(
+ "a %s's colors",
+ (type) => {
+ it("apply to its child blocks", () => {
+ const container = mount(type);
+
+ expect(getComputedStyle(childContent(container)).color).toBe(RED);
+ expect(backgroundBehind(childContent(container))).toBe(BLUE_BACKGROUND);
+ });
+
+ it("follow changes to the block's props", () => {
+ const container = mount(type);
+
+ editor!.updateBlock("parent", {
+ props: { textColor: "green", backgroundColor: "default" },
+ });
+
+ expect(getComputedStyle(childContent(container)).color).toBe(GREEN);
+ expect(backgroundBehind(childContent(container))).not.toBe(
+ BLUE_BACKGROUND,
+ );
+ });
+
+ it("apply to its child blocks in the internal HTML", () => {
+ mount(type);
+ const html = document.createElement("div");
+ html.className = "bn-default-styles";
+ html.innerHTML = editor!.blocksToFullHTML(editor!.document);
+ document.body.appendChild(html);
+ try {
+ expect(getComputedStyle(childContent(html)).color).toBe(RED);
+ expect(backgroundBehind(childContent(html))).toBe(BLUE_BACKGROUND);
+ } finally {
+ html.remove();
+ }
+ });
+ },
+);
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..b498498769 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,67 @@ describe("Exporter missing mappings", () => {
).toThrow('missing a style mapping for style "bold"');
});
});
+
+// A container block (`container: true`), and an exporter
+// whose mappings return strings.
+const box = createBlockSpec(
+ {
+ type: "box",
+ propSchema: {},
+ content: "none",
+ container: true,
+ },
+ {
+ render: () => {
+ const dom = document.createElement("div");
+ return { dom, contentDOM: dom };
+ },
+ },
+)();
+
+class StringExporter extends Exporter {
+ constructor(blockMapping: Record) {
+ super(
+ BlockNoteSchema.create().extend({ blockSpecs: { box } }),
+ { blockMapping, inlineContentMapping: {}, styleMapping: {} } as any,
+ { colors: COLORS_DEFAULT },
+ );
+ }
+
+ public transformStyledText(_styledText: StyledText) {
+ return undefined;
+ }
+}
+
+describe("Exporter child placement", () => {
+ it("passes a `{ withChildren }` mapping the block's rendered children", async () => {
+ const exporter = new StringExporter({
+ box: {
+ withChildren: (_b: any, _e: any, _n: any, _i: any, c: string[]) =>
+ `[${c.join(",")}]`,
+ },
+ });
+
+ expect(exporter.placesChildren({ type: "box" })).toBe(true);
+ await expect(
+ exporter.mapBlock({ type: "box" } as any, 0, 0, ["a", "b"]),
+ ).resolves.toBe("[a,b]");
+ });
+
+ it("throws when a container block has a plain mapping", async () => {
+ const exporter = new StringExporter({ box: () => "box" });
+
+ expect(exporter.placesChildren({ type: "box" })).toBe(false);
+ await expect(
+ exporter.mapBlock({ type: "box" } as any, 0, 0, []),
+ ).rejects.toThrow("must be a `{ withChildren }` mapping");
+ });
+
+ it("leaves the children of an unmapped or plainly mapped block to the exporter", () => {
+ // 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().placesChildren({ type: "mathBlock" }),
+ ).toBe(false);
+ });
+});
diff --git a/packages/core/src/exporter/Exporter.ts b/packages/core/src/exporter/Exporter.ts
index 6ca0efa222..e82e5ea026 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,15 @@ export abstract class Exporter<
public readonly options: ExporterOptions,
) {}
+ /**
+ * Whether the block's mapping places its children itself (a `{ withChildren }`
+ * mapping). Otherwise the exporter places them after the block.
+ */
+ public placesChildren(block: { type: string }): boolean {
+ const mapping = this.mappings.blockMapping[block.type];
+ return typeof mapping === "object" && mapping !== null;
+ }
+
/**
* The strings this exporter renders into the produced document - the
* `exporter` section of the configured dictionary (the `dictionary`
@@ -139,7 +148,7 @@ export abstract class Exporter<
public abstract transformStyledText(styledText: StyledText): TS;
public async mapBlock(
- block: BlockFromConfig,
+ block: BlockNoDefaults,
nestingLevel: number,
numberedListIndex: number,
children?: Array>,
@@ -150,6 +159,26 @@ export abstract class Exporter<
`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);
+ if (typeof mapping === "function") {
+ // A container's children belong inside it, which only a
+ // `{ withChildren }` mapping can do. Fail early rather than export them
+ // after it.
+ // TODO: remove once the `BlockMapping` type requires `{ withChildren }`
+ // for containers (`createBlockSpec` doesn't keep `container: true` in the
+ // config type yet).
+ if (this.schema.blockSpecs[block.type]?.config.container === true) {
+ throw new Error(
+ `The mapping for container block type "${block.type}" must be a \`{ withChildren }\` mapping, which places the block's children.`,
+ );
+ }
+ return mapping(block, this, nestingLevel, numberedListIndex);
+ }
+ return mapping.withChildren(
+ block,
+ this,
+ nestingLevel,
+ numberedListIndex,
+ children ?? [],
+ );
}
}
diff --git a/packages/core/src/exporter/mapping.ts b/packages/core/src/exporter/mapping.ts
index fbe88d693e..dceb584600 100644
--- a/packages/core/src/exporter/mapping.ts
+++ b/packages/core/src/exporter/mapping.ts
@@ -11,6 +11,13 @@ import type { Exporter } from "./Exporter.js";
/**
* Defines a mapping from all block types with a schema to a result type `R`.
+ * Each block type maps to either:
+ * - a function that renders the block itself. The exporter places the
+ * block's children after it, nested as the format does it; or
+ * - `{ withChildren }`, a function that renders the block *and* its
+ * children, which it receives already rendered as its last argument. For
+ * blocks whose children are part of them, like a column or a callout's
+ * body. Container blocks must use it.
*/
export type BlockMapping<
B extends BlockSchema,
@@ -19,18 +26,42 @@ export type BlockMapping<
RB,
RI,
> = {
- [K in keyof B]: (
- block: BlockFromConfigNoChildren,
- // we don't know the exact types that are supported by the exporter at this point,
- // because the mapping only knows about converting certain types (which might be a subset of the supported types)
- // this is why there are many `any` types here (same for types below)
- exporter: Exporter,
- nestingLevel: number,
- numberedListIndex?: number,
- children?: Array>,
- ) => RB | Promise;
+ [K in keyof B]:
+ | BlockMappingFunction
+ | { withChildren: BlockMappingWithChildrenFunction };
};
+export type BlockMappingFunction<
+ C extends BlockSchema[string],
+ I extends InlineContentSchema,
+ S extends StyleSchema,
+ RB,
+ RI,
+> = (
+ block: BlockFromConfigNoChildren,
+ // we don't know the exact types that are supported by the exporter at this point,
+ // because the mapping only knows about converting certain types (which might be a subset of the supported types)
+ // this is why there are many `any` types here (same for types below)
+ exporter: Exporter,
+ nestingLevel: number,
+ numberedListIndex?: number,
+) => RB | Promise;
+
+/** A `{ withChildren }` mapping: it also receives the block's rendered children. */
+export type BlockMappingWithChildrenFunction<
+ C extends BlockSchema[string],
+ I extends InlineContentSchema,
+ S extends StyleSchema,
+ RB,
+ RI,
+> = (
+ block: BlockFromConfigNoChildren,
+ exporter: Exporter,
+ nestingLevel: number,
+ numberedListIndex: number | undefined,
+ children: Array>,
+) => RB | Promise;
+
/**
* Defines a mapping from all inline content types with a schema to a result type R.
*/
diff --git a/packages/core/src/extensions/DropCursor/DropCursor.ts b/packages/core/src/extensions/DropCursor/DropCursor.ts
index 77d24cc114..01017ca24a 100644
--- a/packages/core/src/extensions/DropCursor/DropCursor.ts
+++ b/packages/core/src/extensions/DropCursor/DropCursor.ts
@@ -1,3 +1,4 @@
+import { Plugin } from "prosemirror-state";
import { dropPoint } from "prosemirror-transform";
import type { EditorView } from "prosemirror-view";
import {
@@ -10,6 +11,7 @@ import {
} from "./utils.js";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { createExtension } from "../../editor/BlockNoteExtension.js";
+import { getDropIntoChildren } from "./dropIntoChildren.js";
export const DRAG_EXCLUSION_CLASSNAME = "bn-drag-exclude";
@@ -64,6 +66,11 @@ export const DropCursorExtension = createExtension<
let element: HTMLElement | null = null;
let timeout = -1;
let dragSourceElement: Element | null = null;
+ // The block the drop goes into, if any, as the cursor shows it. Its
+ // element is highlighted like a selected block.
+ let dropInto:
+ | { blockId: string; draggedBlocks: any[]; element: HTMLElement }
+ | undefined;
const config = {
width: options.dropCursor?.width ?? 5,
@@ -73,6 +80,14 @@ export const DropCursorExtension = createExtension<
} as const;
// Helper functions
+ const setDropInto = (into: typeof dropInto) => {
+ delete dropInto?.element.dataset.dropTarget;
+ dropInto = into;
+ if (dropInto) {
+ dropInto.element.dataset.dropTarget = "true";
+ }
+ };
+
const setCursor = (pos: DropCursorPosition | null) => {
if (
pos?.pos === cursorPos?.pos &&
@@ -83,6 +98,7 @@ export const DropCursorExtension = createExtension<
cursorPos = pos;
if (pos == null) {
+ setDropInto(undefined);
if (element && element.parentNode) {
element.parentNode.removeChild(element);
}
@@ -189,13 +205,17 @@ export const DropCursorExtension = createExtension<
}
}
- // Compute default position
+ // Compute default position. A block dragged onto a block with
+ // `meta.dropsIntoChildren` goes to the start of its children.
const $pos = view.state.doc.resolve(target);
const isBlock = !$pos.parent.inlineContent;
- const defaultPosition: DropCursorPosition = {
- pos: target,
- orientation: isBlock ? "block-horizontal" : "inline",
- };
+ const intoChildren = getDropIntoChildren(editor, view, {
+ left: e.clientX,
+ top: e.clientY,
+ });
+ const defaultPosition: DropCursorPosition = intoChildren
+ ? { pos: intoChildren.pos, orientation: "block-horizontal" }
+ : { pos: target, orientation: isBlock ? "block-horizontal" : "inline" };
// Allow hook to override position
let finalPosition = defaultPosition;
@@ -215,6 +235,26 @@ export const DropCursorExtension = createExtension<
}
setCursor(finalPosition);
+ // The drop goes into the block while the cursor shows the place of its
+ // children. A hook may show another place instead (e.g. a new column).
+ if (
+ intoChildren &&
+ finalPosition.pos === intoChildren.pos &&
+ finalPosition.orientation === "block-horizontal"
+ ) {
+ const { block } = intoChildren.blockInfo;
+ const element = view.nodeDOM(block.beforePos);
+ if (!(element instanceof HTMLElement)) {
+ throw new Error("A block in the document must have an element");
+ }
+ setDropInto({
+ blockId: block.node.attrs.id,
+ draggedBlocks: intoChildren.draggedBlocks,
+ element,
+ });
+ } else {
+ setDropInto(undefined);
+ }
scheduleRemoval(5000);
}
};
@@ -238,8 +278,49 @@ export const DropCursorExtension = createExtension<
dragSourceElement = null;
};
+ // Drops the blocks where the cursor shows them: as the first children of
+ // the block it goes into. Other drops are ProseMirror's.
+ const dropIntoChildrenPlugin = new Plugin({
+ props: {
+ handleDrop(_view, _event, _slice, moved) {
+ const into = dropInto;
+ // The drop ends the drag, and with it the highlight.
+ setDropInto(undefined);
+ // The block may be gone since the last `dragover`, e.g. removed by a
+ // collaborator. ProseMirror then drops the blocks as usual.
+ if (!into || !editor.getBlock(into.blockId)) {
+ return false;
+ }
+ const { blockId, draggedBlocks } = into;
+ editor.transact(() => {
+ if (moved) {
+ // A drag from another editor leaves its blocks there.
+ editor.removeBlocks(
+ draggedBlocks.filter((block) => editor.getBlock(block.id)),
+ );
+ }
+ // The dragged blocks never contain the target (see
+ // `getDropIntoChildren`), so removing them keeps it.
+ const target = editor.getBlock(blockId);
+ if (!target) {
+ throw new Error(
+ "The drop target was removed with the dragged blocks",
+ );
+ }
+ if (target.children.length > 0) {
+ editor.insertBlocks(draggedBlocks, target.children[0], "before");
+ } else {
+ editor.updateBlock(target, { children: draggedBlocks });
+ }
+ });
+ return true;
+ },
+ },
+ });
+
return {
key: "dropCursor",
+ prosemirrorPlugins: [dropIntoChildrenPlugin],
mount({ signal, dom, root }) {
// Track drag source at document level
root.addEventListener("dragstart", onDragStart, {
diff --git a/packages/core/src/extensions/DropCursor/dropIntoChildren.ts b/packages/core/src/extensions/DropCursor/dropIntoChildren.ts
new file mode 100644
index 0000000000..453c704c81
--- /dev/null
+++ b/packages/core/src/extensions/DropCursor/dropIntoChildren.ts
@@ -0,0 +1,80 @@
+import type { EditorView } from "prosemirror-view";
+
+import { fragmentToBlocks } from "../../api/nodeConversions/fragmentToBlocks.js";
+import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js";
+import { getBlockInfoFromNode } from "../../api/getBlockInfoFromPos.js";
+import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+
+/**
+ * Where a block drag at `coords` drops, when it is over the content or frame
+ * chrome of a block with `meta.dropsIntoChildren`: the start of that block's
+ * children, or its end when it has no children yet.
+ *
+ * @returns `undefined` when the drop goes before or after a block, as usual:
+ * for a text drag, over a block's children, over a block without the
+ * setting, or over one of the dragged blocks.
+ */
+export function getDropIntoChildren(
+ editor: BlockNoteEditor,
+ view: EditorView,
+ coords: { left: number; top: number },
+) {
+ const slice = view.dragging?.slice;
+ // A block drag holds complete blocks (see `SideMenu.onDragStart`).
+ if (!slice || slice.openStart > 0) {
+ return undefined;
+ }
+ const draggedBlocks = fragmentToBlocks(slice.content);
+ const pos = view.posAtCoords(coords);
+ if (draggedBlocks.length === 0 || !pos || pos.inside < 0) {
+ return undefined;
+ }
+
+ // The innermost block around the pointer. Over its children group (e.g. a
+ // gap between two children), the drop goes between them.
+ const $inside = view.state.doc.resolve(pos.inside + 1);
+ let depth = $inside.depth;
+ while (depth > 0 && !$inside.node(depth).type.isInGroup("bnBlock")) {
+ if ($inside.node(depth).type.name === "blockGroup") {
+ return undefined;
+ }
+ depth--;
+ }
+ if (depth === 0) {
+ return undefined;
+ }
+ const blockInfo = getBlockInfoFromNode(
+ $inside.node(depth),
+ $inside.before(depth),
+ );
+ if (!blockInfo.hasContent) {
+ return undefined;
+ }
+
+ const dropsIntoChildren = editor.schema.blockSpecs[
+ blockInfo.blockNoteType
+ ]?.implementation.meta?.dropsIntoChildren?.(
+ nodeToBlock(blockInfo.block.node, view.state.doc),
+ );
+ if (!dropsIntoChildren) {
+ return undefined;
+ }
+
+ // A block can't be dropped into itself or into one of its descendants.
+ const targetId = blockInfo.block.node.attrs.id;
+ const containsTarget = (block: {
+ id: string;
+ children: { id: string; children: any[] }[];
+ }): boolean => block.id === targetId || block.children.some(containsTarget);
+ if (draggedBlocks.some(containsTarget)) {
+ return undefined;
+ }
+
+ return {
+ blockInfo,
+ draggedBlocks,
+ pos: blockInfo.children
+ ? blockInfo.children.childrenStart
+ : blockInfo.block.afterPos - 1,
+ };
+}
diff --git a/packages/core/src/extensions/Placeholder/Placeholder.ts b/packages/core/src/extensions/Placeholder/Placeholder.ts
index fedcbadbdc..da3f0c81f3 100644
--- a/packages/core/src/extensions/Placeholder/Placeholder.ts
+++ b/packages/core/src/extensions/Placeholder/Placeholder.ts
@@ -18,14 +18,13 @@ export const PlaceholderExtension = createExtension(
Pick, "placeholders">
>) => {
const placeholders = options.placeholders;
+ const uniqueEditorSelector = `placeholder-selector-${uuidv4()}`;
return {
key: "placeholder",
prosemirrorPlugins: [
new Plugin({
key: PLUGIN_KEY,
view: (view) => {
- const uniqueEditorSelector = `placeholder-selector-${uuidv4()}`;
- view.dom.classList.add(uniqueEditorSelector);
const styleEl = document.createElement("style");
const nonce = editor._tiptapEditor.options.injectNonce;
@@ -96,6 +95,10 @@ export const PlaceholderExtension = createExtension(
};
},
props: {
+ // Set through ProseMirror, not on `view.dom` directly: ProseMirror
+ // observes the editor element, and flushes a change made outside
+ // it later, which can outlive the editor.
+ attributes: { class: uniqueEditorSelector },
decorations: (state) => {
const { doc, selection } = state;
diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts
index 112803b95d..5d4da252ce 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/SourceBlockWithPreview/SourceBlockWithPreview.ts b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts
index c9e8648861..845d41b99f 100644
--- a/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts
+++ b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts
@@ -57,7 +57,8 @@ export const SourceBlockWithPreviewExtension = createExtension(
key: "sourceBlockWithPreview",
store,
keyboardShortcuts: {
- // Toggles the popup. This may be overridden by `hardBreakShortcut`.
+ // Toggles the popup, unless Enter inserts line breaks in the block
+ // (`keyboard.enter: "line-break"`).
Enter: ({ editor }) => {
const { block } = editor.getTextCursorPosition();
if (!blockHasPreview(block)) {
@@ -66,8 +67,8 @@ export const SourceBlockWithPreviewExtension = createExtension(
if (
store.state.popupOpen === block.id &&
- editor.schema.blockSpecs[block.type]?.implementation?.meta
- ?.hardBreakShortcut === "enter"
+ editor.schema.blockSpecs[block.type].implementation.keyboard(block)
+ .enter === "line-break"
) {
const view = editor.prosemirrorView!;
view.dispatch(view.state.tr.insertText("\n"));
diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts
index 756c2c1c36..30497bb8de 100644
--- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts
+++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts
@@ -1,5 +1,9 @@
import { Block, PartialBlock } from "../../blocks/defaultBlocks.js";
import { editorHasBlockWithType } from "../../blocks/defaultBlockTypeGuards.js";
+import {
+ type DefaultBlockTypeItem,
+ getDefaultBlockTypeItems,
+} from "../../blocks/defaultBlockTypeItems.js";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import {
BlockSchema,
@@ -99,103 +103,39 @@ export function getDefaultSlashMenuItems<
>(editor: BlockNoteEditor) {
const items: DefaultSuggestionItem[] = [];
- if (editorHasBlockWithType(editor, "heading", { level: "number" })) {
- (editor.schema.blockSchema.heading.propSchema.level.values || [])
- .filter((level): level is 1 | 2 | 3 => level <= 3)
- .forEach((level) => {
- items.push({
- onItemClick: () => {
- insertOrUpdateBlockForSlashMenu(editor, {
- type: "heading",
- props: { level: level },
- });
- },
- badge: formatKeyboardShortcut(`Mod-Alt-${level}`),
- key:
- level === 1 ? ("heading" as const) : (`heading_${level}` as const),
- ...editor.dictionary.slash_menu[
- level === 1 ? ("heading" as const) : (`heading_${level}` as const)
- ],
- });
- });
- }
-
- if (editorHasBlockWithType(editor, "quote")) {
- items.push({
- onItemClick: () => {
- insertOrUpdateBlockForSlashMenu(editor, {
- type: "quote",
- });
- },
- key: "quote",
- ...editor.dictionary.slash_menu.quote,
- });
- }
-
- if (editorHasBlockWithType(editor, "toggleListItem")) {
- items.push({
- onItemClick: () => {
- insertOrUpdateBlockForSlashMenu(editor, {
- type: "toggleListItem",
- });
- },
- badge: formatKeyboardShortcut("Mod-Shift-6"),
- key: "toggle_list",
- ...editor.dictionary.slash_menu.toggle_list,
- });
- }
-
- if (editorHasBlockWithType(editor, "numberedListItem")) {
- items.push({
- onItemClick: () => {
- insertOrUpdateBlockForSlashMenu(editor, {
- type: "numberedListItem",
- });
- },
- badge: formatKeyboardShortcut("Mod-Shift-7"),
- key: "numbered_list",
- ...editor.dictionary.slash_menu.numbered_list,
- });
- }
-
- if (editorHasBlockWithType(editor, "bulletListItem")) {
- items.push({
- onItemClick: () => {
- insertOrUpdateBlockForSlashMenu(editor, {
- type: "bulletListItem",
- });
- },
- badge: formatKeyboardShortcut("Mod-Shift-8"),
- key: "bullet_list",
- ...editor.dictionary.slash_menu.bullet_list,
- });
- }
-
- if (editorHasBlockWithType(editor, "checkListItem")) {
+ // The block types come from `getDefaultBlockTypeItems`, which the block
+ // type select also uses. The slash menu places them in its own order.
+ const blockTypes = new Map(
+ getDefaultBlockTypeItems(editor).map((item) => [item.key, item]),
+ );
+ function pushBlockType(key: DefaultBlockTypeItem["key"], badge?: string) {
+ const blockType = blockTypes.get(key);
+ if (!blockType) {
+ return;
+ }
items.push({
onItemClick: () => {
+ // The schema supports the block type (see `getDefaultBlockTypeItems`).
insertOrUpdateBlockForSlashMenu(editor, {
- type: "checkListItem",
- });
+ type: blockType.type,
+ props: blockType.props,
+ } as PartialBlock);
},
- badge: formatKeyboardShortcut("Mod-Shift-9"),
- key: "check_list",
- ...editor.dictionary.slash_menu.check_list,
+ badge,
+ key,
+ ...editor.dictionary.slash_menu[key],
});
}
- if (editorHasBlockWithType(editor, "paragraph")) {
- items.push({
- onItemClick: () => {
- insertOrUpdateBlockForSlashMenu(editor, {
- type: "paragraph",
- });
- },
- badge: formatKeyboardShortcut("Mod-Alt-0"),
- key: "paragraph",
- ...editor.dictionary.slash_menu.paragraph,
- });
- }
+ pushBlockType("heading", formatKeyboardShortcut("Mod-Alt-1"));
+ pushBlockType("heading_2", formatKeyboardShortcut("Mod-Alt-2"));
+ pushBlockType("heading_3", formatKeyboardShortcut("Mod-Alt-3"));
+ pushBlockType("quote");
+ pushBlockType("toggle_list", formatKeyboardShortcut("Mod-Shift-6"));
+ pushBlockType("numbered_list", formatKeyboardShortcut("Mod-Shift-7"));
+ pushBlockType("bullet_list", formatKeyboardShortcut("Mod-Shift-8"));
+ pushBlockType("check_list", formatKeyboardShortcut("Mod-Shift-9"));
+ pushBlockType("paragraph", formatKeyboardShortcut("Mod-Alt-0"));
if (editorHasBlockWithType(editor, "codeBlock")) {
items.push({
@@ -324,52 +264,12 @@ export function getDefaultSlashMenuItems<
});
}
- if (
- editorHasBlockWithType(editor, "heading", {
- level: "number",
- isToggleable: "boolean",
- })
- ) {
- (editor.schema.blockSchema.heading.propSchema.level.values || [])
- .filter((level): level is 1 | 2 | 3 => level <= 3)
- .forEach((level) => {
- items.push({
- onItemClick: () => {
- insertOrUpdateBlockForSlashMenu(editor, {
- type: "heading",
- props: { level: level, isToggleable: true },
- });
- },
- key:
- level === 1
- ? ("toggle_heading" as const)
- : (`toggle_heading_${level}` as const),
- ...editor.dictionary.slash_menu[
- level === 1
- ? ("toggle_heading" as const)
- : (`toggle_heading_${level}` as const)
- ],
- });
- });
- }
-
- if (editorHasBlockWithType(editor, "heading", { level: "number" })) {
- (editor.schema.blockSchema.heading.propSchema.level.values || [])
- .filter((level): level is 4 | 5 | 6 => level > 3)
- .forEach((level) => {
- items.push({
- onItemClick: () => {
- insertOrUpdateBlockForSlashMenu(editor, {
- type: "heading",
- props: { level: level },
- });
- },
- badge: formatKeyboardShortcut(`Mod-Alt-${level}`),
- key: `heading_${level}`,
- ...editor.dictionary.slash_menu[`heading_${level}`],
- });
- });
- }
+ pushBlockType("toggle_heading");
+ pushBlockType("toggle_heading_2");
+ pushBlockType("toggle_heading_3");
+ pushBlockType("heading_4", formatKeyboardShortcut("Mod-Alt-4"));
+ pushBlockType("heading_5", formatKeyboardShortcut("Mod-Alt-5"));
+ pushBlockType("heading_6", formatKeyboardShortcut("Mod-Alt-6"));
items.push({
onItemClick: () => {
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 093e4d2728..4315dca9e8 100644
--- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts
+++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts
@@ -16,12 +16,8 @@ import {
import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
import { createBlockSpec } from "../../../schema/index.js";
-// The `hardBreakShortcut` setting lives on the block spec's implementation
-// (`schema.blockSpecs[type].implementation.meta`), not on the block config in
-// `schema.blockSchema`. These blocks verify that the Enter / Shift-Enter
-// handlers read it from the right place — a previous regression read it from
-// `blockSchema`, which never contains `meta`, so custom settings were silently
-// ignored and every block behaved as "shift+enter".
+// Blocks configured with the deprecated `meta.hardBreakShortcut`, which still
+// sets the default of `keyboard.enter` and `keyboard.shiftEnter`.
const createHardBreakTestBlockSpec = <
const T extends string,
const S extends "shift+enter" | "enter" | "none",
@@ -51,9 +47,73 @@ const createHardBreakTestBlockSpec = <
},
)();
+// The same blocks, configured with the `experimental_keyboard` settings that
+// replace the
+// deprecated `meta.hardBreakShortcut`.
+const createKeyboardTestBlockSpec = <
+ const T extends string,
+ const C extends "inline" | "plain",
+>(
+ type: T,
+ keyboard: {
+ enter?: "split" | "into-children" | "line-break";
+ shiftEnter?: "line-break" | "same-as-enter";
+ },
+ content: C = "inline" as C,
+) =>
+ createBlockSpec(
+ {
+ type,
+ propSchema: {},
+ content,
+ },
+ {
+ experimental_keyboard: keyboard,
+ render: () => {
+ const dom = document.createElement("p");
+ return {
+ dom,
+ contentDOM: dom,
+ };
+ },
+ },
+ )();
+
+// A block whose keyboard function leaves settings `undefined` when a prop is
+// off. Those settings must keep their defaults.
+const conditionalKeyboardBlock = createBlockSpec(
+ {
+ type: "conditionalKeyboard",
+ propSchema: { on: { default: false } },
+ content: "inline",
+ },
+ {
+ experimental_keyboard: (block) => ({
+ enter: block.props.on ? "into-children" : undefined,
+ resetsTo: block.props.on ? { type: "heading" } : undefined,
+ }),
+ render: () => {
+ const dom = document.createElement("p");
+ return { dom, contentDOM: dom };
+ },
+ },
+)();
+
const schema = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
+ conditionalKeyboard: conditionalKeyboardBlock,
+ keyboardEnter: createKeyboardTestBlockSpec("keyboardEnter", {
+ enter: "line-break",
+ }),
+ keyboardNone: createKeyboardTestBlockSpec("keyboardNone", {
+ shiftEnter: "same-as-enter",
+ }),
+ keyboardEnterPlain: createKeyboardTestBlockSpec(
+ "keyboardEnterPlain",
+ { enter: "line-break" },
+ "plain",
+ ),
hardBreakEnter: createHardBreakTestBlockSpec("hardBreakEnter", "enter"),
hardBreakNone: createHardBreakTestBlockSpec("hardBreakNone", "none"),
// "plain" content (`text*`) can't hold a `hardBreak` node, so these blocks
@@ -71,7 +131,10 @@ function createEditor(
| "paragraph"
| "hardBreakEnter"
| "hardBreakNone"
- | "hardBreakEnterPlain",
+ | "hardBreakEnterPlain"
+ | "keyboardEnter"
+ | "keyboardNone"
+ | "keyboardEnterPlain",
) {
const editor = BlockNoteEditor.create({
schema,
@@ -97,6 +160,22 @@ function pressKeys(editor: BlockNoteEditor, keys: string) {
editor._tiptapEditor.commands.keyboardShortcut(keys);
}
+/**
+ * Dispatches a keydown event straight to the view's handlers. Unlike
+ * `pressKeys`, whose command loses the selection and stored marks the handler
+ * sets, this leaves the state as a real key press does. Use it for tests that
+ * check the caret or the styles of what is typed next.
+ */
+function keyDown(
+ editor: BlockNoteEditor,
+ init: KeyboardEventInit,
+) {
+ const view = editor._tiptapEditor.view;
+ view.someProp("handleKeyDown", (handler) =>
+ handler(view, new KeyboardEvent("keydown", init)),
+ );
+}
+
function countHardBreaks(editor: BlockNoteEditor) {
let count = 0;
editor._tiptapEditor.state.doc.descendants((node) => {
@@ -255,6 +334,121 @@ describe("KeyboardShortcutsExtension Backspace", () => {
`);
editor._tiptapEditor.destroy();
});
+ // The block above isn't rich text, but its last child is: the text joins
+ // that child, as after any block with children.
+ it("merges into the last child of a block above whose own content isn't inline", () => {
+ const editor = createEditorWithBlocks(
+ [
+ {
+ id: "code",
+ type: "codeBlock",
+ content: "x",
+ children: [{ id: "child", type: "paragraph", content: "Child" }],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ],
+ { id: "after", placement: "start" },
+ );
+
+ pressKeys(editor, "Backspace");
+
+ expect(outline(editor.document)).toMatchInlineSnapshot(`
+ [
+ {
+ "children": [
+ {
+ "text": "ChildAfter",
+ "type": "paragraph",
+ },
+ ],
+ "text": "x",
+ "type": "codeBlock",
+ },
+ ]
+ `);
+ editor._tiptapEditor.destroy();
+ });
+
+ it("does not merge rich text into a code block above", () => {
+ const editor = createEditorWithBlocks(
+ [
+ { id: "code", type: "codeBlock", content: "x" },
+ { id: "after", type: "paragraph", content: "After" },
+ ],
+ { id: "after", placement: "start" },
+ );
+
+ pressKeys(editor, "Backspace");
+
+ expect(outline(editor.document)).toMatchInlineSnapshot(`
+ [
+ {
+ "text": "x",
+ "type": "codeBlock",
+ },
+ {
+ "text": "After",
+ "type": "paragraph",
+ },
+ ]
+ `);
+ editor._tiptapEditor.destroy();
+ });
+
+ // #2566: the caret used to jump to the end of the top-level block above,
+ // instead of the last block nested under it.
+ it("in an empty block moves the caret to the end of the deepest last block above", () => {
+ const editor = createEditorWithBlocks(
+ [
+ {
+ id: "list",
+ type: "bulletListItem",
+ content: "One",
+ children: [
+ { id: "nested", type: "bulletListItem", content: "Two" },
+ { id: "last", type: "bulletListItem" },
+ ],
+ },
+ { id: "empty", type: "paragraph" },
+ ],
+ { id: "empty", placement: "start" },
+ );
+
+ keyDown(editor, { key: "Backspace", keyCode: 8 });
+
+ expect(editor.getBlock("empty")).toBeUndefined();
+ expect(editor.getTextCursorPosition().block.id).toBe("last");
+ editor._tiptapEditor.destroy();
+ });
+
+ // #605: Backspace in an empty block below an image used to delete the image
+ // too.
+ it("in an empty block below a block without content deletes only the empty block", () => {
+ const editor = createEditorWithBlocks(
+ [
+ { id: "image", type: "image" },
+ { id: "empty", type: "paragraph" },
+ ],
+ { id: "empty", placement: "start" },
+ );
+
+ pressKeys(editor, "Backspace");
+
+ expect(editor.document.map((block) => block.id)).toEqual(["image"]);
+ editor._tiptapEditor.destroy();
+ });
+ it("keeps the default of a keyboard setting given as undefined", () => {
+ const editor = createEditorWithBlocks(
+ [{ id: "a", type: "conditionalKeyboard", content: "Text" }],
+ { id: "a", placement: "start" },
+ );
+
+ // `resetsTo` is undefined for this block, so the default applies.
+ pressKeys(editor, "Backspace");
+
+ expect(editor.getBlock("a")!.type).toBe("paragraph");
+ editor._tiptapEditor.destroy();
+ });
});
describe("KeyboardShortcutsExtension Delete", () => {
@@ -373,29 +567,6 @@ describe("KeyboardShortcutsExtension Delete", () => {
});
describe("KeyboardShortcutsExtension Enter", () => {
- it("inserts an empty block above when Enter is pressed at the start", () => {
- const editor = createEditorWithBlocks(
- [{ id: "a", type: "paragraph", content: "Hello" }],
- { id: "a", placement: "start" },
- );
-
- pressKeys(editor, "Enter");
-
- expect(outline(editor.document)).toMatchInlineSnapshot(`
- [
- {
- "text": "",
- "type": "paragraph",
- },
- {
- "text": "Hello",
- "type": "paragraph",
- },
- ]
- `);
- editor._tiptapEditor.destroy();
- });
-
it("lifts an empty nested block on Enter", () => {
const editor = createEditorWithBlocks(
[
@@ -459,7 +630,7 @@ describe("KeyboardShortcutsExtension Shift-Tab", () => {
});
});
-describe("KeyboardShortcutsExtension hardBreakShortcut", () => {
+describe("KeyboardShortcutsExtension line breaks", () => {
it("inserts a hard break on Shift-Enter by default", () => {
const editor = createEditor("paragraph");
@@ -471,6 +642,33 @@ describe("KeyboardShortcutsExtension hardBreakShortcut", () => {
editor._tiptapEditor.destroy();
});
+ // #1672: text typed after the line break used to lose the styles of the
+ // text before it.
+ it("keeps the styles of the text before the line break", () => {
+ const editor = createEditorWithBlocks(
+ [
+ {
+ id: "p",
+ type: "paragraph",
+ content: [
+ { type: "text", text: "Red", styles: { textColor: "red" } },
+ ],
+ },
+ ],
+ { id: "p", placement: "end" },
+ );
+
+ keyDown(editor, { key: "Enter", keyCode: 13, shiftKey: true });
+ // Typing: `insertText` takes the stored marks, as the browser input does.
+ const view = editor._tiptapEditor.view;
+ view.dispatch(view.state.tr.insertText("x"));
+
+ expect(editor.getBlock("p")!.content).toEqual([
+ { type: "text", text: "Red\nx", styles: { textColor: "red" } },
+ ]);
+ editor._tiptapEditor.destroy();
+ });
+
it("splits the block on Enter by default", () => {
const editor = createEditor("paragraph");
@@ -481,73 +679,130 @@ describe("KeyboardShortcutsExtension hardBreakShortcut", () => {
editor._tiptapEditor.destroy();
});
+});
+
+describe.each([
+ {
+ setting: "meta.hardBreakShortcut (deprecated)",
+ enter: "hardBreakEnter",
+ none: "hardBreakNone",
+ plain: "hardBreakEnterPlain",
+ },
+ {
+ setting: "keyboard",
+ enter: "keyboardEnter",
+ none: "keyboardNone",
+ plain: "keyboardEnterPlain",
+ },
+] as const)(
+ "hard breaks configured with $setting",
+ ({ enter, none, plain }) => {
+ it("inserts a hard break on Enter when Enter makes line breaks", () => {
+ const editor = createEditor(enter);
- it('inserts a hard break on Enter when hardBreakShortcut is "enter"', () => {
- const editor = createEditor("hardBreakEnter");
+ pressKeys(editor, "Enter");
- pressKeys(editor, "Enter");
+ expect(countHardBreaks(editor)).toBe(1);
+ expect(editor.document.length).toBe(1);
- expect(countHardBreaks(editor)).toBe(1);
- expect(editor.document.length).toBe(1);
+ editor._tiptapEditor.destroy();
+ });
- editor._tiptapEditor.destroy();
- });
+ it("inserts a hard break on Shift-Enter when Enter makes line breaks", () => {
+ const editor = createEditor(enter);
- it('inserts a hard break on Shift-Enter when hardBreakShortcut is "enter"', () => {
- const editor = createEditor("hardBreakEnter");
+ pressKeys(editor, "Shift-Enter");
- pressKeys(editor, "Shift-Enter");
+ expect(countHardBreaks(editor)).toBe(1);
+ expect(editor.document.length).toBe(1);
- expect(countHardBreaks(editor)).toBe(1);
- expect(editor.document.length).toBe(1);
+ editor._tiptapEditor.destroy();
+ });
- editor._tiptapEditor.destroy();
- });
+ it("does not insert a hard break on Shift-Enter when it acts as Enter", () => {
+ const editor = createEditor(none);
- it('does not insert a hard break on Shift-Enter when hardBreakShortcut is "none"', () => {
- const editor = createEditor("hardBreakNone");
+ pressKeys(editor, "Shift-Enter");
- pressKeys(editor, "Shift-Enter");
+ expect(countHardBreaks(editor)).toBe(0);
- expect(countHardBreaks(editor)).toBe(0);
+ editor._tiptapEditor.destroy();
+ });
- editor._tiptapEditor.destroy();
- });
+ it("splits the block on Enter when Shift-Enter acts as Enter", () => {
+ const editor = createEditor(none);
- it('splits the block on Enter when hardBreakShortcut is "none"', () => {
- const editor = createEditor("hardBreakNone");
+ pressKeys(editor, "Enter");
- pressKeys(editor, "Enter");
+ expect(countHardBreaks(editor)).toBe(0);
+ expect(editor.document.length).toBe(2);
- expect(countHardBreaks(editor)).toBe(0);
- expect(editor.document.length).toBe(2);
+ editor._tiptapEditor.destroy();
+ });
- editor._tiptapEditor.destroy();
- });
+ it('inserts a newline character on Enter when content is "plain"', () => {
+ const editor = createEditor(plain);
- it('inserts a newline character on Enter when content is "plain"', () => {
- const editor = createEditor("hardBreakEnterPlain");
+ pressKeys(editor, "Enter");
- pressKeys(editor, "Enter");
+ // A "plain" block can't hold a `hardBreak` node, so no node is inserted and
+ // the block is not split - a literal newline is added to its text instead.
+ expect(countHardBreaks(editor)).toBe(0);
+ expect(editor.document.length).toBe(1);
+ expect(getTextContent(editor)).toBe("Hello world\n");
- // A "plain" block can't hold a `hardBreak` node, so no node is inserted and
- // the block is not split - a literal newline is added to its text instead.
- expect(countHardBreaks(editor)).toBe(0);
- expect(editor.document.length).toBe(1);
- expect(getTextContent(editor)).toBe("Hello world\n");
+ editor._tiptapEditor.destroy();
+ });
- editor._tiptapEditor.destroy();
- });
+ it('inserts a newline character on Shift-Enter when content is "plain"', () => {
+ const editor = createEditor(plain);
- it('inserts a newline character on Shift-Enter when content is "plain"', () => {
- const editor = createEditor("hardBreakEnterPlain");
+ pressKeys(editor, "Shift-Enter");
- pressKeys(editor, "Shift-Enter");
+ expect(countHardBreaks(editor)).toBe(0);
+ expect(editor.document.length).toBe(1);
+ expect(getTextContent(editor)).toBe("Hello world\n");
- expect(countHardBreaks(editor)).toBe(0);
- expect(editor.document.length).toBe(1);
- expect(getTextContent(editor)).toBe("Hello world\n");
+ 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 8cb3cc5fab..3b089ec5cc 100644
--- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
+++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
@@ -1,29 +1,80 @@
-import { Extension } from "@tiptap/core";
-import { Fragment, Node } from "prosemirror-model";
-import { NodeSelection, TextSelection } from "prosemirror-state";
+import { type ChainedCommands, Extension } from "@tiptap/core";
+import { Fragment, type Node } from "prosemirror-model";
+import { NodeSelection, 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 { nodeToBlock } from "../../../api/nodeConversions/nodeToBlock.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";
@@ -33,6 +84,17 @@ export const KeyboardShortcutsExtension = Extension.create<{
// TODO: The shortcuts need a refactor. Do we want to use a command priority
// design as there is now, or clump the logic into a single function?
addKeyboardShortcuts() {
+ const bnEditor = this.options.editor;
+ // The `keyboard` settings of the block that `node` holds, in `doc`.
+ function keyboardOf(node: Node, doc: Node) {
+ const block = nodeToBlock(node, doc);
+ return bnEditor.schema.blockSpecs[block.type].implementation.keyboard(
+ block,
+ );
+ }
+ function canOutdentFrom(doc: Node) {
+ return (parent: Node) => keyboardOf(parent, doc).childrenCanOutdent;
+ }
// handleBackspace is partially adapted from https://github.com/ueberdosis/tiptap/blob/ed56337470efb4fd277128ab7ef792b37cfae992/packages/core/src/extensions/keymap.ts
const handleBackspace = () =>
this.editor.commands.first(({ chain, commands }) => [
@@ -40,7 +102,9 @@ export const KeyboardShortcutsExtension = Extension.create<{
() => commands.deleteSelection(),
// Undoes an input rule if one was triggered in the last editor state change.
() => commands.undoInputRule(),
- // Reverts block content type to a paragraph if the selection is at the start of the block.
+ // Resets the block (`keyboard.resetsTo`, a paragraph by default) if the
+ // selection is at the start of the block and the block isn't already
+ // in that form.
() =>
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
@@ -49,20 +113,28 @@ export const KeyboardShortcutsExtension = Extension.create<{
}
const selectionAtBlockStart =
- state.selection.from === blockInfo.content.beforePos + 1;
- const isParagraph =
- blockInfo.content.node.type.name === "paragraph";
-
- if (selectionAtBlockStart && !isParagraph) {
- return commands.command(
- updateBlockCommand(blockInfo.block.beforePos, {
- type: "paragraph",
- props: {},
- }),
+ state.selection.from === blockInfo.contentStart;
+ if (!selectionAtBlockStart) {
+ return false;
+ }
+
+ const resetsTo = keyboardOf(
+ blockInfo.block.node,
+ state.doc,
+ ).resetsTo;
+ const content = blockInfo.content.node;
+ const alreadyReset =
+ content.type.name === resetsTo.type &&
+ Object.entries(resetsTo.props ?? {}).every(
+ ([prop, value]) => content.attrs[prop] === value,
);
+ if (alreadyReset) {
+ return false;
}
- return false;
+ return commands.command(
+ updateBlockCommand(blockInfo.block.beforePos, resetsTo as any),
+ );
}),
// Merges at the start of the block, into the preceding sibling
// (or its deepest descendant) or parent. Both must have inline content.
@@ -72,20 +144,26 @@ export const KeyboardShortcutsExtension = Extension.create<{
if (!blockInfo.hasContent) {
return false;
}
- const { block, content } = blockInfo;
+ const { block: blockContainer } = blockInfo;
- // Crossing a column-list boundary moves the block into the last
- // column first; the following handler owns that operation.
- const prevBlockInfo = getPrevBlockInfo(state.doc, block.beforePos);
- if (prevBlockInfo && !prevBlockInfo.hasContent) {
+ const prevSibling = getPrevBlockInfo(
+ state.doc,
+ blockInfo.block.beforePos,
+ );
+ // A preceding container takes the move branch below. Otherwise
+ // the block merges into the block above (its last descendant), or
+ // into its parent when it has no previous sibling;
+ // mergeBlocksCommand decides whether their contents are
+ // compatible.
+ if (prevSibling && !prevSibling.hasContent) {
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()
@@ -96,93 +174,65 @@ 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.
+ // 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) {
- 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.
- () =>
- 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) {
+ 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;
}),
// Removes an empty inline block when merging is impossible. Its
@@ -245,7 +295,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(
@@ -254,6 +304,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) {
@@ -305,6 +358,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
tr,
tr.doc.type.schema.nodes["blockContainer"],
tr.doc.type.schema.nodes["blockGroup"],
+ canOutdentFrom(tr.doc),
);
}),
]);
@@ -324,56 +378,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,
);
}
@@ -388,7 +420,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
if (!blockInfo.hasContent) {
return false;
}
- const { block, content } = blockInfo;
+ const { block: blockContainer } = blockInfo;
const nextBlockInfo = getNextBlockInfo(
state.doc,
@@ -399,10 +431,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()
@@ -413,103 +445,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;
+ let $boundary = tr.doc.resolve(blockInfo.block.afterPos);
+ while (
+ !$boundary.nodeAfter &&
+ $boundary.depth > 0 &&
+ isContainerNode($boundary.parent.type)
+ ) {
+ $boundary = tr.doc.resolve($boundary.after());
}
-
- 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) {
+ const nextNode = $boundary.nodeAfter;
+ if (!nextNode) {
return false;
}
- const selectionAtBlockEnd =
- tr.selection.from === blockInfo.content.afterPos - 1;
- if (!selectionAtBlockEnd) {
+ const crossedBoundary = $boundary.pos !== blockInfo.block.afterPos;
+ if (!crossedBoundary && !isContainerNode(nextNode.type)) {
return false;
}
-
- const $pos = tr.doc.resolve(blockInfo.block.afterPos);
-
- const nextBlock = $pos.nodeAfter;
- if (nextBlock) {
- 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
@@ -523,66 +501,36 @@ 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,
+ nextBlockInfo.block,
);
}
@@ -598,8 +546,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(
@@ -610,21 +557,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({
@@ -649,7 +589,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(
@@ -670,7 +610,6 @@ export const KeyboardShortcutsExtension = Extension.create<{
nextBlockInfo.isContentEmpty);
if (nextBlockNotTableAndNoContent) {
- const childBlocks = nextBlockInfo.block.node.lastChild!.content;
return chain()
.deleteRange({
from: nextBlockInfo.block.beforePos,
@@ -678,9 +617,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
})
.insertContentAt(
blockInfo.block.afterPos,
- nextBlockInfo.block.node.childCount === 2
- ? childBlocks
- : null,
+ nextBlockInfo.children?.node.content ?? null,
)
.run();
}
@@ -692,6 +629,30 @@ export const KeyboardShortcutsExtension = Extension.create<{
const handleEnter = (withShift = false) => {
return this.editor.commands.first(({ commands, tr }) => [
+ // Resets an empty block (`keyboard.resetsTo`) if it resets on Enter
+ // (`keyboard.emptyEnterResets`), e.g. an empty list item turns into a
+ // paragraph. Its children stay.
+ () =>
+ commands.command(({ state }) => {
+ const blockInfo = getBlockInfoFromSelection(state);
+ if (
+ !blockInfo.hasContent ||
+ !state.selection.empty ||
+ !blockInfo.isContentEmpty
+ ) {
+ return false;
+ }
+ const keyboard = keyboardOf(blockInfo.block.node, state.doc);
+ if (!keyboard.emptyEnterResets) {
+ return false;
+ }
+ return commands.command(
+ updateBlockCommand(
+ blockInfo.block.beforePos,
+ keyboard.resetsTo as any,
+ ),
+ );
+ }),
// Removes a level of nesting if the block is empty & indented, while the selection is also empty & at the start
// of the block.
() =>
@@ -700,15 +661,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 (
@@ -717,16 +678,32 @@ export const KeyboardShortcutsExtension = Extension.create<{
blockEmpty &&
blockIndented
) {
+ // Only outdents where the parent says so
+ // (`keyboard.emptyChildEnter: "outdent"`); otherwise the block
+ // leaves at the end, or a new child is added, further down.
+ const parent = getParentBlockInfo(
+ state.doc,
+ blockContainer.beforePos,
+ );
+ if (
+ parent &&
+ keyboardOf(parent.block.node, state.doc).emptyChildEnter !==
+ "outdent"
+ ) {
+ return false;
+ }
return liftItem(
tr,
tr.doc.type.schema.nodes["blockContainer"],
tr.doc.type.schema.nodes["blockGroup"],
+ canOutdentFrom(tr.doc),
);
}
return false;
}),
- // Creates a hard break if block is configured to do so.
+ // Creates a hard break if the block is configured to do so
+ // (`keyboard.enter` / `keyboard.shiftEnter`).
() =>
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
@@ -734,29 +711,12 @@ 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";
-
- if (blockHardBreakShortcut === "none") {
- return false;
- }
+ const keyboard = keyboardOf(blockInfo.block.node, state.doc);
if (
- // If shortcut is not configured, or is configured as "shift+enter",
- // create a hard break for shift+enter, but not for enter.
- (blockHardBreakShortcut === "shift+enter" && withShift) ||
- // If shortcut is configured as "enter", create a hard break for
- // both enter and shift+enter.
- blockHardBreakShortcut === "enter"
+ // Enter as a line break makes Shift-Enter one too.
+ keyboard.enter === "line-break" ||
+ (withShift && keyboard.shiftEnter === "line-break")
) {
// "plain" blocks (e.g. code/math/diagram source) hold text only
// (their content is `text*`), which can't contain a `hardBreak`
@@ -786,6 +746,70 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
+ // If the block is empty and the last child of a block whose empty
+ // children exit at the end (`keyboard.emptyChildEnter:
+ // "exit-at-end"`, the default for containers), 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) {
+ return false;
+ }
+ if (
+ keyboardOf(owner.block.node, tr.doc).emptyChildEnter !==
+ "exit-at-end"
+ ) {
+ 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.
() =>
@@ -794,16 +818,22 @@ 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 &&
+ keyboardOf(blockInfo.block.node, state.doc).enter !==
+ "into-children"
+ ) {
+ const newBlockInsertionPos = blockContainer.afterPos;
const newBlockContentPos = newBlockInsertionPos + 2;
if (dispatch) {
@@ -842,6 +872,105 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
+ // Enter at the start of non-empty content inserts an empty block above
+ // it: the same type when the block's splits keep their type (lists),
+ // a paragraph otherwise, with default props. The block itself, with
+ // its id, props (e.g. a checklist item's checked state) and children,
+ // stays where it is (#550).
+ () =>
+ commands.command(({ state, tr, dispatch }) => {
+ const blockInfo = getBlockInfoFromSelection(state);
+ if (
+ !blockInfo.hasContent ||
+ blockInfo.contentKind === "table" ||
+ !state.selection.empty ||
+ blockInfo.isContentEmpty ||
+ state.selection.from !== blockInfo.contentStart
+ ) {
+ return false;
+ }
+
+ if (dispatch) {
+ const contentType = keyboardOf(blockInfo.block.node, state.doc)
+ .splitKeepsType
+ ? blockInfo.content.node.type
+ : state.schema.nodes["paragraph"];
+ const newBlock = state.schema.nodes["blockContainer"].create(
+ undefined,
+ contentType.create(),
+ );
+ tr.insert(blockInfo.block.beforePos, newBlock).scrollIntoView();
+ }
+
+ return true;
+ }),
+ // Enter in the content of a block whose Enter goes into its children
+ // (`keyboard.enter: "into-children"`, e.g. an open toggle) starts its
+ // children rather than splitting the block in two: whatever follows
+ // the cursor becomes the first child, and the existing children stay
+ // where they are.
+ () =>
+ commands.command(({ state, tr, dispatch }) => {
+ const blockInfo = getBlockInfoFromSelection(state);
+ if (!blockInfo.hasContent) {
+ return false;
+ }
+
+ if (
+ keyboardOf(blockInfo.block.node, state.doc).enter !==
+ "into-children"
+ ) {
+ 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.
() =>
@@ -850,19 +979,21 @@ 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) {
+ const keepType =
+ selectionAtBlockStart ||
+ keyboardOf(blockInfo.block.node, state.doc).splitKeepsType;
chain()
.deleteSelection()
.command(
splitBlockCommand(
state.selection.from,
- selectionAtBlockStart,
+ keepType,
selectionAtBlockStart,
),
)
@@ -877,42 +1008,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/extensions/tiptap-extensions/KeyboardShortcuts/blockIdentity.browser.test.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/blockIdentity.browser.test.ts
new file mode 100644
index 0000000000..b0366f9f7a
--- /dev/null
+++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/blockIdentity.browser.test.ts
@@ -0,0 +1,152 @@
+import { afterEach, describe, expect, it } from "vite-plus/test";
+import { userEvent } from "vite-plus/test/browser";
+
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+import type { PartialBlock } from "../../../blocks/defaultBlocks.js";
+
+// Editing at the start of a block keeps the block's identity (#550): Enter
+// keeps its id and props, such as a checklist item's checked state. Backspace
+// below an empty block of the same type and props moves the block up, keeping
+// its id and children. Below an empty block of another type, the text moves
+// into that block, which keeps its id, type and props, as in Notion (#3124).
+
+let editor: BlockNoteEditor;
+let root: HTMLElement;
+
+function mount(content: PartialBlock[]) {
+ root = document.createElement("div");
+ document.body.appendChild(root);
+ editor = BlockNoteEditor.create({ initialContent: content });
+ editor.mount(root);
+}
+
+afterEach(() => {
+ editor._tiptapEditor.destroy();
+ root.remove();
+});
+
+async function press(
+ key: string,
+ at: { block: string; placement?: "start" | "end" },
+) {
+ editor.setTextCursorPosition(at.block, at.placement ?? "start");
+ editor.focus();
+ await userEvent.keyboard(`{${key}}`);
+}
+
+describe("Enter at the start of a non-empty block", () => {
+ it("inserts an empty paragraph above a paragraph, which keeps its id", async () => {
+ mount([{ id: "p", type: "paragraph", content: "Text" }]);
+
+ await press("Enter", { block: "p" });
+
+ const [inserted, block] = editor.document;
+ expect(inserted.type).toBe("paragraph");
+ expect(inserted.content).toEqual([]);
+ expect(block.id).toBe("p");
+ expect(editor.getTextCursorPosition().block.id).toBe("p");
+ });
+
+ it("keeps a checklist item's checked state with its text", async () => {
+ mount([
+ {
+ id: "c",
+ type: "checkListItem",
+ props: { checked: true },
+ content: "Done",
+ },
+ ]);
+
+ await press("Enter", { block: "c" });
+
+ const [inserted, block] = editor.document;
+ expect(inserted.type).toBe("checkListItem");
+ expect(inserted.props).toMatchObject({ checked: false });
+ expect(block.id).toBe("c");
+ expect(block.props).toMatchObject({ checked: true });
+ });
+
+ it("inserts an empty paragraph above a heading, which keeps its id and level", async () => {
+ mount([
+ { id: "h", type: "heading", props: { level: 3 }, content: "Heading" },
+ ]);
+
+ await press("Enter", { block: "h" });
+
+ const [inserted, block] = editor.document;
+ expect(inserted.type).toBe("paragraph");
+ expect(block.id).toBe("h");
+ expect(block.props).toMatchObject({ level: 3 });
+ });
+
+ it("keeps the block's children with it", async () => {
+ mount([
+ {
+ id: "p",
+ type: "paragraph",
+ content: "Parent",
+ children: [{ id: "c1", type: "paragraph", content: "Child" }],
+ },
+ ]);
+
+ await press("Enter", { block: "p" });
+
+ const [inserted, block] = editor.document;
+ expect(inserted.children).toHaveLength(0);
+ expect(block.id).toBe("p");
+ expect(block.children.map((child) => child.id)).toEqual(["c1"]);
+ });
+});
+
+describe("Backspace at the start of a block after an empty block", () => {
+ it("moves the block up when the empty block has the same type and props, keeping its id and children", async () => {
+ mount([
+ { id: "empty", type: "paragraph" },
+ {
+ id: "p",
+ type: "paragraph",
+ content: "Text",
+ children: [{ id: "c", type: "paragraph", content: "Child" }],
+ },
+ ]);
+
+ await press("Backspace", { block: "p" });
+
+ expect(editor.document.map((block) => block.id)).toEqual(["p"]);
+ expect(editor.getBlock("p")!.children.map((child) => child.id)).toEqual([
+ "c",
+ ]);
+ expect(editor.getTextCursorPosition().block.id).toBe("p");
+ });
+
+ it("moves the text into an empty block of another type, which keeps its id, type and props", async () => {
+ mount([
+ { id: "empty", type: "heading", props: { level: 2 } },
+ { id: "p", type: "paragraph", content: "Text" },
+ ]);
+
+ await press("Backspace", { block: "p" });
+
+ const [block] = editor.document;
+ expect(editor.document).toHaveLength(1);
+ expect(block.id).toBe("empty");
+ expect(block.type).toBe("heading");
+ expect(block.props).toMatchObject({ level: 2 });
+ expect(block.content).toEqual([{ type: "text", text: "Text", styles: {} }]);
+ expect(editor.getTextCursorPosition().block.id).toBe("empty");
+ });
+});
+
+describe("Delete in an empty block before a block", () => {
+ it("moves the next block up when it has the same type and props, keeping its id", async () => {
+ mount([
+ { id: "empty", type: "paragraph" },
+ { id: "p", type: "paragraph", content: "Text" },
+ ]);
+
+ await press("Delete", { block: "empty", placement: "end" });
+
+ expect(editor.document.map((block) => block.id)).toEqual(["p"]);
+ expect(editor.getTextCursorPosition().block.id).toBe("p");
+ });
+});
diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts
index 1c19b810dd..f275cbb081 100644
--- a/packages/core/src/i18n/locales/ar.ts
+++ b/packages/core/src/i18n/locales/ar.ts
@@ -184,6 +184,7 @@ export const ar: Dictionary = {
},
toggle_blocks: {
add_block_button: "تبديل فارغ. انقر لإضافة كتلة.",
+ toggle_button: "توسيع أو طي",
},
code_block: {
add_source_button_text: "إضافة كود المصدر",
diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts
index 45ff9341d8..a844a3bb87 100644
--- a/packages/core/src/i18n/locales/de.ts
+++ b/packages/core/src/i18n/locales/de.ts
@@ -220,6 +220,7 @@ export const de: Dictionary = {
toggle_blocks: {
add_block_button:
"Leerer aufklappbarer Bereich. Klicken, um einen Block hinzuzufügen.",
+ toggle_button: "Aufklappen oder zuklappen",
},
code_block: {
add_source_button_text: "Quellcode hinzufügen",
diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts
index 307ba90c22..3093145710 100644
--- a/packages/core/src/i18n/locales/en.ts
+++ b/packages/core/src/i18n/locales/en.ts
@@ -199,6 +199,7 @@ export const en = {
},
toggle_blocks: {
add_block_button: "Empty toggle. Click to add a block.",
+ toggle_button: "Expand or collapse",
},
code_block: {
add_source_button_text: "Add source code",
diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts
index b2c05ca6b2..64b77f59a7 100644
--- a/packages/core/src/i18n/locales/es.ts
+++ b/packages/core/src/i18n/locales/es.ts
@@ -199,6 +199,7 @@ export const es: Dictionary = {
},
toggle_blocks: {
add_block_button: "Toggle vacío. Haz clic para añadir un bloque.",
+ toggle_button: "Expandir o contraer",
},
code_block: {
add_source_button_text: "Agregar código fuente",
diff --git a/packages/core/src/i18n/locales/fa.ts b/packages/core/src/i18n/locales/fa.ts
index 405cf87ddf..f50e0c9dbb 100644
--- a/packages/core/src/i18n/locales/fa.ts
+++ b/packages/core/src/i18n/locales/fa.ts
@@ -167,6 +167,7 @@ export const fa = {
},
toggle_blocks: {
add_block_button: "تاشوی خالی. برای افزودن بلوک کلیک کنید.",
+ toggle_button: "باز یا بسته کردن",
},
code_block: {
add_source_button_text: "افزودن کد منبع",
diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts
index 4807927655..ac2e5ddb14 100644
--- a/packages/core/src/i18n/locales/fr.ts
+++ b/packages/core/src/i18n/locales/fr.ts
@@ -245,6 +245,7 @@ export const fr: Dictionary = {
},
toggle_blocks: {
add_block_button: "Liste repliable vide. Cliquez pour ajouter un bloc.",
+ toggle_button: "Déplier ou replier",
},
code_block: {
add_source_button_text: "Ajouter le code source",
diff --git a/packages/core/src/i18n/locales/he.ts b/packages/core/src/i18n/locales/he.ts
index 1b9338b77b..ef2d86f869 100644
--- a/packages/core/src/i18n/locales/he.ts
+++ b/packages/core/src/i18n/locales/he.ts
@@ -201,6 +201,7 @@ export const he: Dictionary = {
},
toggle_blocks: {
add_block_button: "מתג ריק. לחץ כדי להוסיף בלוק.",
+ toggle_button: "הרחבה או כיווץ",
},
code_block: {
add_source_button_text: "הוסף קוד מקור",
diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts
index 998a245f20..1cabe84f5e 100644
--- a/packages/core/src/i18n/locales/hr.ts
+++ b/packages/core/src/i18n/locales/hr.ts
@@ -212,6 +212,7 @@ export const hr: Dictionary = {
},
toggle_blocks: {
add_block_button: "Prazan sklopivi blok. Klikni da dodaš sadržaj.",
+ toggle_button: "Proširi ili sažmi",
},
code_block: {
add_source_button_text: "Dodaj izvorni kôd",
diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts
index e7effe3827..419d72043a 100644
--- a/packages/core/src/i18n/locales/is.ts
+++ b/packages/core/src/i18n/locales/is.ts
@@ -213,6 +213,7 @@ export const is: Dictionary = {
},
toggle_blocks: {
add_block_button: "Tóm fellilína. Smelltu til að bæta við blokk.",
+ toggle_button: "Fella út eða inn",
},
code_block: {
add_source_button_text: "Bæta við frumkóða",
diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts
index 782a3c7fc4..6e2fa9c46f 100644
--- a/packages/core/src/i18n/locales/it.ts
+++ b/packages/core/src/i18n/locales/it.ts
@@ -221,6 +221,7 @@ export const it: Dictionary = {
},
toggle_blocks: {
add_block_button: "Toggle vuoto. Clicca per aggiungere un blocco.",
+ toggle_button: "Espandi o comprimi",
},
code_block: {
add_source_button_text: "Aggiungi codice sorgente",
diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts
index 8bac14021d..521e77ae3f 100644
--- a/packages/core/src/i18n/locales/ja.ts
+++ b/packages/core/src/i18n/locales/ja.ts
@@ -239,6 +239,7 @@ export const ja: Dictionary = {
},
toggle_blocks: {
add_block_button: "空のトグルです。クリックしてブロックを追加。",
+ toggle_button: "展開または折りたたみ",
},
code_block: {
add_source_button_text: "ソースコードを追加",
diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts
index de94329b19..9bdd466fbc 100644
--- a/packages/core/src/i18n/locales/ko.ts
+++ b/packages/core/src/i18n/locales/ko.ts
@@ -212,6 +212,7 @@ export const ko: Dictionary = {
},
toggle_blocks: {
add_block_button: "비어 있는 토글입니다. 클릭하여 블록을 추가하세요.",
+ toggle_button: "펼치기 또는 접기",
},
code_block: {
add_source_button_text: "소스 코드 추가",
diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts
index a90210b572..7d515e0750 100644
--- a/packages/core/src/i18n/locales/nl.ts
+++ b/packages/core/src/i18n/locales/nl.ts
@@ -200,6 +200,7 @@ export const nl: Dictionary = {
},
toggle_blocks: {
add_block_button: "Lege uitklapper. Klik om een blok toe te voegen.",
+ toggle_button: "Uitklappen of inklappen",
},
code_block: {
add_source_button_text: "Broncode toevoegen",
diff --git a/packages/core/src/i18n/locales/no.ts b/packages/core/src/i18n/locales/no.ts
index 9ed6388dc7..34534d07f8 100644
--- a/packages/core/src/i18n/locales/no.ts
+++ b/packages/core/src/i18n/locales/no.ts
@@ -218,6 +218,7 @@ export const no: Dictionary = {
},
toggle_blocks: {
add_block_button: "Tomt toggle. Klikk for å legge til en blokk.",
+ toggle_button: "Utvid eller skjul",
},
code_block: {
add_source_button_text: "Legg til kildekode",
diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts
index 95751640b9..da6055e1d0 100644
--- a/packages/core/src/i18n/locales/pl.ts
+++ b/packages/core/src/i18n/locales/pl.ts
@@ -191,6 +191,7 @@ export const pl: Dictionary = {
toggle_blocks: {
add_block_button:
"Brak bloków do rozwinięcia. Kliknij, aby dodać pierwszego.",
+ toggle_button: "Rozwiń lub zwiń",
},
code_block: {
add_source_button_text: "Dodaj kod źródłowy",
diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts
index 6914de9d2c..c47c3b4f74 100644
--- a/packages/core/src/i18n/locales/pt.ts
+++ b/packages/core/src/i18n/locales/pt.ts
@@ -191,6 +191,7 @@ export const pt: Dictionary = {
},
toggle_blocks: {
add_block_button: "Toggle vazio. Clique para adicionar um bloco.",
+ toggle_button: "Expandir ou recolher",
},
code_block: {
add_source_button_text: "Adicionar código-fonte",
diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts
index db116a3c4c..3bb8cbf251 100644
--- a/packages/core/src/i18n/locales/ru.ts
+++ b/packages/core/src/i18n/locales/ru.ts
@@ -242,6 +242,7 @@ export const ru: Dictionary = {
},
toggle_blocks: {
add_block_button: "Пустой переключатель. Нажмите, чтобы добавить блок.",
+ toggle_button: "Развернуть или свернуть",
},
code_block: {
add_source_button_text: "Добавить исходный код",
diff --git a/packages/core/src/i18n/locales/sk.ts b/packages/core/src/i18n/locales/sk.ts
index f53c4c39d1..1aa1c1d8ad 100644
--- a/packages/core/src/i18n/locales/sk.ts
+++ b/packages/core/src/i18n/locales/sk.ts
@@ -199,6 +199,7 @@ export const sk = {
},
toggle_blocks: {
add_block_button: "Prázdne prepínanie. Kliknite pre pridanie bloku.",
+ toggle_button: "Rozbaliť alebo zbaliť",
},
code_block: {
add_source_button_text: "Pridať zdrojový kód",
diff --git a/packages/core/src/i18n/locales/uk.ts b/packages/core/src/i18n/locales/uk.ts
index e6101c8f69..7f0b80ae38 100644
--- a/packages/core/src/i18n/locales/uk.ts
+++ b/packages/core/src/i18n/locales/uk.ts
@@ -224,6 +224,7 @@ export const uk: Dictionary = {
},
toggle_blocks: {
add_block_button: "Порожній перемикач. Натисніть, щоб додати блок.",
+ toggle_button: "Розгорнути або згорнути",
},
code_block: {
add_source_button_text: "Додати вихідний код",
diff --git a/packages/core/src/i18n/locales/uz.ts b/packages/core/src/i18n/locales/uz.ts
index 23b0f4f1a7..826e64bfcf 100644
--- a/packages/core/src/i18n/locales/uz.ts
+++ b/packages/core/src/i18n/locales/uz.ts
@@ -260,6 +260,7 @@ export const uz: Dictionary = {
toggle_blocks: {
add_block_button: "Bo‘sh toggle. Blok qo‘shish uchun bosing.",
+ toggle_button: "Yoyish yoki yig‘ish",
},
code_block: {
diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts
index d52db4d48d..3373d7be16 100644
--- a/packages/core/src/i18n/locales/vi.ts
+++ b/packages/core/src/i18n/locales/vi.ts
@@ -198,6 +198,7 @@ export const vi: Dictionary = {
},
toggle_blocks: {
add_block_button: "Toggle trống. Nhấp để thêm khối.",
+ toggle_button: "Mở rộng hoặc thu gọn",
},
code_block: {
add_source_button_text: "Thêm mã nguồn",
diff --git a/packages/core/src/i18n/locales/zh-tw.ts b/packages/core/src/i18n/locales/zh-tw.ts
index 0aba71ead4..333e9a2563 100644
--- a/packages/core/src/i18n/locales/zh-tw.ts
+++ b/packages/core/src/i18n/locales/zh-tw.ts
@@ -240,6 +240,7 @@ export const zhTW: Dictionary = {
},
toggle_blocks: {
add_block_button: "空的切換區。點擊新增區塊。",
+ toggle_button: "展開或收合",
},
code_block: {
add_source_button_text: "新增原始碼",
diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts
index 0017c86672..22dbc5b19a 100644
--- a/packages/core/src/i18n/locales/zh.ts
+++ b/packages/core/src/i18n/locales/zh.ts
@@ -240,6 +240,7 @@ export const zh: Dictionary = {
},
toggle_blocks: {
add_block_button: "空的切换区。点击添加区块。",
+ toggle_button: "展开或收起",
},
code_block: {
add_source_button_text: "添加源代码",
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index de5faa2fc7..16ce41df3e 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..0fb1258e18 100644
--- a/packages/core/src/pm-nodes/BlockContainer.ts
+++ b/packages/core/src/pm-nodes/BlockContainer.ts
@@ -1,10 +1,91 @@
-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 { ignoreFrameChromeMutations } from "../schema/nodeViewMutations.js";
import { mergeCSSClasses } from "../util/browser.js";
import { suggestionMarks } from "./suggestionMarks.js";
+const dropEvents = new Set(["dragenter", "dragover", "dragleave", "drop"]);
+
+/** 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);
+ },
+ };
+}
+
+/**
+ * Puts a block's text and background color on its own element (`.bn-block`),
+ * so they also apply to its children however deep a frame puts its content.
+ * Only these two props, which `Block.css` applies to a block's children.
+ */
+export function setBlockColorAttributes(
+ element: HTMLElement,
+ props: Record | undefined,
+) {
+ for (const [prop, attribute] of [
+ ["textColor", "data-text-color"],
+ ["backgroundColor", "data-background-color"],
+ ] as const) {
+ const value = props?.[prop];
+ if (typeof value === "string" && value !== "default") {
+ element.setAttribute(attribute, value);
+ } else {
+ element.removeAttribute(attribute);
+ }
+ }
+}
+
// Object containing all possible block attributes.
const BlockAttributes: Record = {
blockColor: "data-block-color",
@@ -88,4 +169,65 @@ 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);
+ }
+ setBlockColorAttributes(contentDOM, props.node.firstChild?.attrs);
+
+ const nodeView: NodeView = {
+ dom,
+ contentDOM: frameView.contentDOM ?? contentDOM,
+ update(node, decorations, innerDecorations) {
+ // Changing the wrapper or block type replaces the complete view.
+ const kept =
+ node.sameMarkup(props.node) &&
+ node.firstChild?.type === props.node.firstChild?.type &&
+ (frameView.update?.(node, decorations, innerDecorations) ?? false);
+ if (kept) {
+ // The view stays when only the content's attributes change.
+ setBlockColorAttributes(contentDOM, node.firstChild?.attrs);
+ }
+ return kept;
+ },
+ stopEvent(event) {
+ // Author chrome handles its own events; the slot remains editable.
+ // Drag-and-drop events still go to ProseMirror, so a block dropped
+ // on the chrome drops where the drop cursor shows it.
+ const target = event.target;
+ return (
+ (target instanceof globalThis.Node &&
+ frameView.dom.contains(target) &&
+ !nodeView.contentDOM?.contains(target) &&
+ !dropEvents.has(event.type)) ||
+ (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) {
+ ignoreFrameChromeMutations(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..cf0fed0411
--- /dev/null
+++ b/packages/core/src/schema/blocks/children.test.ts
@@ -0,0 +1,150 @@
+// @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("accepts any blocks when `children` or `allow` is left out", () => {
+ expect(childrenContentExpression()).toBe("blockGroupChild+");
+ expect(childrenContentExpression({ min: 2 })).toBe("blockGroupChild{2,}");
+ });
+
+ it("throws for an allow array that permits nothing", () => {
+ expect(() => childrenContentExpression({ allow: [] })).toThrow(
+ /permits nothing/,
+ );
+ });
+});
+
+type BlockFixture = {
+ content?: "none" | "inline" | "plain" | "table";
+ container?: true;
+ children?: ChildrenConfig;
+ placeable?: "anywhere" | "namedOnly";
+};
+
+function specsWith(blocks: Record) {
+ return {
+ paragraph: { config: { content: "inline" as const } },
+ heading: { config: { content: "inline" as const } },
+ ...Object.fromEntries(
+ Object.entries(blocks).map(([type, config]) => [
+ type,
+ { config: { ...config, content: config.content ?? ("none" as const) } },
+ ]),
+ ),
+ };
+}
+
+// Typed loosely on purpose: the validator is what catches the combinations the
+// types reject, for JS callers.
+const validate = (blocks: Record) => () =>
+ validateChildrenConfigs(specsWith(blocks) as any);
+
+describe("validateChildrenConfigs", () => {
+ it("accepts recursive containers and named-only children", () => {
+ expect(validate({ callout: { container: true } })).not.toThrow();
+ expect(
+ validate({
+ // gridCell is a terminating alternative to the recursive grid.
+ grid: {
+ container: true,
+ children: { allow: ["gridCell", "grid"], min: 2 },
+ },
+ gridCell: { container: true, placeable: "namedOnly" },
+ }),
+ ).not.toThrow();
+ });
+
+ it.each(["inline", "plain", "table"] as const)(
+ "rejects `container` on a block with %s content",
+ (content) => {
+ expect(validate({ alert: { content, container: true } })).toThrow(
+ /only for blocks without content/,
+ );
+ },
+ );
+
+ // Any block can have any child blocks, so writing out the default is fine
+ // everywhere. Restricting them needs the block's own node.
+ it.each(["inline", "plain", "table", "none"] as const)(
+ "only accepts the default children on a %s block that isn't a container",
+ (content) => {
+ expect(
+ validate({ alert: { content, children: { allow: "blocks" } } }),
+ ).not.toThrow();
+ for (const children of [
+ { allow: "blocks", min: 2 },
+ { allow: ["cell"] },
+ ] as const) {
+ expect(
+ validate({
+ alert: { content, children },
+ cell: { container: true },
+ }),
+ ).toThrow(/requires `container: true`/);
+ }
+ },
+ );
+
+ it("does not treat a block with content as an allowed container", () => {
+ expect(
+ validate({
+ box: { container: true, children: { allow: ["alert"] } },
+ alert: { content: "inline" },
+ }),
+ ).toThrow(/regular block/);
+ });
+
+ it("rejects named-only placement on a shared regular block wrapper", () => {
+ expect(
+ validate({ alert: { content: "inline", placeable: "namedOnly" } }),
+ ).toThrow(/requires a container node/);
+ });
+
+ it.each(["typo", "blockGroupChild", "toString"])(
+ "rejects an allow entry that is not a configured block: %s",
+ (allowed) => {
+ expect(
+ validate({ box: { container: true, children: { allow: [allowed] } } }),
+ ).toThrow(/not a configured block type/);
+ },
+ );
+
+ it("rejects a regular block type in the allow array", () => {
+ expect(
+ validate({ box: { container: true, 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..cf132162c3
--- /dev/null
+++ b/packages/core/src/schema/blocks/children.ts
@@ -0,0 +1,103 @@
+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* (`container: true`):
+ * one whose own node holds its children.
+ * @internal
+ */
+export function isContainerConfig(config: { container?: true }): boolean {
+ return config.container === true;
+}
+
+// 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");
+}
+
+// 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 = "blocks", 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..f981a3f9bc
--- /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",
+ container: true,
+ },
+ {
+ 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",
+ container: true,
+ },
+ {
+ 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(
+ "
\ 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/defaultSchema/blocks.tsx b/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx
index 13c5c4a269..9c07706e26 100644
--- a/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx
+++ b/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx
@@ -354,67 +354,77 @@ export const odtBlockMappingForDefaultSchema: BlockMapping<
return ;
},
- column: (_block, exporter, _nestingLevel, _numberedListIndex, children) => {
- const ex = exporter as ODTExporter;
- const style = ex.registerStyle((name) => (
-
-
-
- ));
+ column: {
+ withChildren: (
+ _block,
+ exporter,
+ _nestingLevel,
+ _numberedListIndex,
+ children,
+ ) => {
+ const ex = exporter as ODTExporter;
+ const style = ex.registerStyle((name) => (
+
+
+
+ ));
- return (
- {children}
- );
+ return (
+ {children}
+ );
+ },
},
- columnList: (
- block,
- exporter,
- _nestingLevel,
- _numberedListIndex,
- children,
- ) => {
- const blockWithChildren = block as BlockFromConfig<
- {
- type: "columnList";
- content: "none";
- propSchema: Record;
- },
- any,
- any
- >;
- const ex = exporter as ODTExporter;
- const style = ex.registerStyle((name) => (
-
-
-
- ));
-
- return (
-
- {(blockWithChildren.children || []).map((column, index) => {
- const style = ex.registerStyle((name) => (
-
-
-
- ));
+ columnList: {
+ withChildren: (
+ block,
+ exporter,
+ _nestingLevel,
+ _numberedListIndex,
+ children,
+ ) => {
+ const blockWithChildren = block as BlockFromConfig<
+ {
+ type: "columnList";
+ content: "none";
+ propSchema: Record;
+ },
+ any,
+ any
+ >;
+ const ex = exporter as ODTExporter;
+ const style = ex.registerStyle((name) => (
+
+
+
+ ));
- return ;
- })}
- {children}
-
- );
+ return (
+
+ {(blockWithChildren.children || []).map((column, index) => {
+ const style = ex.registerStyle((name) => (
+
+
+
+ ));
+
+ return ;
+ })}
+ {children}
+
+ );
+ },
},
image: async (block, exporter) => {
diff --git a/packages/xl-odt-exporter/src/odt/odtExporter.test.ts b/packages/xl-odt-exporter/src/odt/odtExporter.test.ts
index 4883fd0387..fd68c230c9 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,102 @@ describe("exporter", () => {
);
});
});
+
+describe("titled blocks", () => {
+ // A titled block: inline content (the title) plus children (the body). The
+ // `{ withChildren }` mapping renders the title and places the children
+ // inside its own section, so transformBlocks must not append them after it
+ // as tab-indented siblings.
+ const Alert = createBlockSpec(
+ {
+ type: "alert" as const,
+ propSchema: {},
+ content: "inline",
+ },
+ {
+ 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("renders a titled block's title and places its children inside", async () => {
+ const exporter = new ODTExporter(
+ alertSchema,
+ {
+ ...odtDefaultSchemaMappings,
+ blockMapping: {
+ ...odtDefaultSchemaMappings.blockMapping,
+ alert: {
+ withChildren: (
+ 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..018800267c 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.placesChildren(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/defaultSchema/blocks.tsx b/packages/xl-pdf-exporter/src/react-pdf/defaultSchema/blocks.tsx
index 44ff2b73e0..31ea106117 100644
--- a/packages/xl-pdf-exporter/src/react-pdf/defaultSchema/blocks.tsx
+++ b/packages/xl-pdf-exporter/src/react-pdf/defaultSchema/blocks.tsx
@@ -172,27 +172,37 @@ export const pdfBlockMappingForDefaultSchema: BlockMapping<
/>
);
},
- column: (block, _exporter, _nestingLevel, _numberedListIndex, children) => {
- return {children};
+ column: {
+ withChildren: (
+ block,
+ _exporter,
+ _nestingLevel,
+ _numberedListIndex,
+ children,
+ ) => {
+ return {children};
+ },
},
- columnList: (
- _block,
- _exporter,
- _nestingLevel,
- _numberedListIndex,
- children,
- ) => {
- return (
-
- {children}
-
- );
+ columnList: {
+ withChildren: (
+ _block,
+ _exporter,
+ _nestingLevel,
+ _numberedListIndex,
+ children,
+ ) => {
+ return (
+
+ {children}
+
+ );
+ },
},
audio: (block, exporter) => {
return (
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..b39e0fceef 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,90 @@ describe("exporter", () => {
);
});
});
+
+describe("titled blocks", () => {
+ // A titled block: inline content (the title) plus children (the body). The
+ // `{ withChildren }` mapping renders the title and places the children
+ // inside its own view, so transformBlocks must not wrap them after it in
+ // padded sibling views.
+ const Alert = createBlockSpec(
+ {
+ type: "alert" as const,
+ propSchema: {},
+ content: "inline",
+ },
+ {
+ 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("renders a titled block's title and places its children inside", async () => {
+ const exporter = new PDFExporter(alertSchema, {
+ ...pdfDefaultSchemaMappings,
+ blockMapping: {
+ ...pdfDefaultSchemaMappings.blockMapping,
+ alert: {
+ withChildren: (
+ 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..9584dc57e1 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.placesChildren(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..2361b1130d 100644
--- a/packages/xl-typst-exporter/src/defaultSchema/blocks.ts
+++ b/packages/xl-typst-exporter/src/defaultSchema/blocks.ts
@@ -159,11 +159,46 @@ 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: {
+ withChildren: (
+ 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: {
+ withChildren: (
+ _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..4f313dbbc3 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",
+ container: true,
+ },
+ {
+ 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). Its
+// `{ withChildren }` mapping renders the title and places the children, so
+// they must arrive as the mapping's `children` argument.
+const Alert = createBlockSpec(
+ {
+ type: "alert" as const,
+ propSchema: {},
+ content: "inline",
+ },
+ {
+ 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: {
+ withChildren: (
+ 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,82 @@ 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: {
+ withChildren: (
+ _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("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..5dd8611b76 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.placesChildren(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 5b0aca0a61..666583b2c7 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -1432,24 +1432,6 @@ export const examples = {
readme:
'In this example, we create a custom `Alert` block which is used to emphasize text, same as in the [minimal `Alert` block example](/examples/custom-schema/alert-block). However, in this example, we also add a command to insert the block via the Slash Menu, and an entry in the Formatting Toolbar\'s Block Type Select to change the current block to an `Alert`.\n\n**Try it out:** Press the "/" key to open the Slash Menu and insert an `Alert` block! Or highlight text in a paragraph, then change the block type to an `Alert` using the Block Type Select in the Formatting Toolbar!\n\n**Relevant Docs:**\n\n- [Minimal Alert Block Example](/examples/custom-schema/alert-block)\n- [Changing Slash Menu Items](/docs/react/components/suggestion-menus)\n- [Changing Block Type Select Items](/docs/react/components/formatting-toolbar)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)',
},
- {
- projectSlug: "toggleable-blocks",
- fullSlug: "custom-schema/toggleable-blocks",
- pathFromRoot: "examples/06-custom-schema/06-toggleable-blocks",
- config: {
- playground: true,
- docs: true,
- author: "matthewlipski",
- tags: ["Basic"],
- },
- title: "Toggleable Custom Blocks",
- group: {
- pathFromRoot: "examples/06-custom-schema",
- slug: "custom-schema",
- },
- readme:
- "This example shows how to create custom blocks with a toggle button to show/hide their children, like with the default toggle heading and list item blocks. This is done using the use the `ToggleWrapper` component from `@blocknote/react`.\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)\n- [Default Schema](/docs/features/blocks)",
- },
{
projectSlug: "configuring-blocks",
fullSlug: "custom-schema/configuring-blocks",
@@ -1486,6 +1468,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, such as a panel containing headings and paragraphs.\n\nThe block sets `container: true` on `BlockConfig`, which 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.\n\nWe also wire up a Slash Menu item to insert the panel.\n\n**Try it out:**\n\n- Press the "/" key inside the panel\'s body and add a code block, heading, or list.\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",
@@ -1565,6 +1574,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 child blocks inside it (a titled block), like a Notion-style callout.\n\nThe block has `content: "inline"`: the title is ordinary inline content — formatting, links, and multiplayer cursors all work — and its child blocks live on `block.children` at runtime. Its `keyboard` settings keep the child blocks inside the callout: Enter in the title adds a first child block, Shift-Tab doesn\'t move child blocks out, and Enter in an empty last child block leaves the callout. `render` draws the title row and `renderFrame` draws the box around the title and child blocks together.\n\nWe also wire up a Slash Menu item to insert the callout.\n\n**Try it out:**\n\n- Press Enter at the end of the callout\'s title to add a block inside the callout.\n- Press Enter in an empty last block inside the callout to leave the callout.\n- Press Backspace at the start of the first block inside the callout to merge it back into the title.\n- Press "/" inside the callout and add a code block, heading, or list.\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",
@@ -1872,7 +1908,7 @@ export const examples = {
dependencies: {
"y-websocket": "^2.1.0",
yjs: "^13.6.27",
- lib0: "^0.2.99",
+ lib0: "^0.2.119",
} as any,
},
title: "Local Storage Versioning (yjs v13)",
@@ -1896,7 +1932,7 @@ export const examples = {
"@y/protocols": "^1.0.6-rc.1",
"@y/websocket": "^4.0.0-3",
"@y/y": "^14.0.0-rc.23",
- lib0: "1.0.0-rc.22",
+ lib0: "^1.0.0-rc.34",
} as any,
},
title: "YHub Multi-Doc",
@@ -1921,7 +1957,7 @@ export const examples = {
"@y/protocols": "^1.0.6-rc.1",
"@y/websocket": "^4.0.0-3",
"@y/y": "^14.0.0-rc.23",
- lib0: "1.0.0-rc.22",
+ lib0: "^1.0.0-rc.34",
} as any,
},
title: "YHub Versioning (@y/y v14)",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 05a537504f..95737b68c0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3386,7 +3386,7 @@ importers:
specifier: ^8.0.0
version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0)
- examples/06-custom-schema/06-toggleable-blocks:
+ examples/06-custom-schema/07-configuring-blocks:
dependencies:
'@blocknote/ariakit':
specifier: latest
@@ -3429,7 +3429,7 @@ importers:
specifier: ^8.0.0
version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0)
- examples/06-custom-schema/07-configuring-blocks:
+ examples/06-custom-schema/08-non-editable-block:
dependencies:
'@blocknote/ariakit':
specifier: latest
@@ -3472,7 +3472,7 @@ importers:
specifier: ^8.0.0
version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0)
- examples/06-custom-schema/08-non-editable-block:
+ examples/06-custom-schema/09-container-block:
dependencies:
'@blocknote/ariakit':
specifier: latest
@@ -3501,6 +3501,9 @@ importers:
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
@@ -3665,6 +3668,52 @@ importers:
specifier: ^8.0.0
version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.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.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0))
+ vite:
+ specifier: ^8.0.0
+ version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.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/end-to-end/toggleblocks/toggleblocks.test.tsx b/tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx
new file mode 100644
index 0000000000..bc2ac6e0ad
--- /dev/null
+++ b/tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx
@@ -0,0 +1,266 @@
+import {
+ type BlockNoteEditor,
+ BlockNoteSchema,
+ type PartialBlock,
+} from "@blocknote/core";
+import "@blocknote/core/fonts/inter.css";
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+import { useCreateBlockNote } from "@blocknote/react";
+import {
+ multiColumnDropCursor,
+ withMultiColumn,
+} from "@blocknote/xl-multi-column";
+import { beforeEach, describe, expect, test } from "vite-plus/test";
+import { render } from "vitest-browser-react";
+import {
+ DRAG_HANDLE_ADD_SELECTOR,
+ DRAG_HANDLE_SELECTOR,
+ EDITOR_SELECTOR,
+} from "../../utils/const.js";
+import { browserName, page, userEvent } from "../../utils/context.js";
+import { sleep, waitForSelector } from "../../utils/editor.js";
+import {
+ getRect,
+ mouseSequence,
+ moveMouseOverElement,
+} from "../../utils/mouse.js";
+
+// UI behaviour of the built-in toggle blocks (toggle heading and toggle list
+// item) that needs the full editor view: drag and drop, the side menu and the
+// placeholder. Keyboard and open-state behaviour is covered by
+// `packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts`.
+// Tests for bugs that are still open use `test.fails`: they state the
+// expected behaviour, so they start failing - and must be switched to `test`
+// - once the bug is fixed.
+
+const schema = withMultiColumn(BlockNoteSchema.create());
+
+let editor: BlockNoteEditor<
+ typeof schema.blockSchema,
+ typeof schema.inlineContentSchema,
+ typeof schema.styleSchema
+>;
+
+function ToggleApp(props: { content: PartialBlock[]; width?: number }) {
+ editor = useCreateBlockNote({
+ schema,
+ dropCursor: multiColumnDropCursor,
+ initialContent: props.content,
+ });
+
+ return (
+