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/custom-blocks.mdx b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
index ff25cf838c..bcad6e9dac 100644
--- a/docs/content/docs/features/custom-schemas/custom-blocks.mdx
+++ b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
@@ -56,6 +56,11 @@ type BlockConfig = {
type: string;
content: "inline" | "plain" | "none";
readonly propSchema: PropSchema;
+ layoutOnly?: boolean;
+ children?:
+ | { allow?: "blocks"; exclude?: string[] }
+ | { allow: string[]; min?: number; dissolve?: boolean };
+ placeable?: "anywhere" | "namedOnly";
};
```
@@ -110,6 +115,15 @@ If you do not want the prop to have a default value, you can define it as an obj
Properties](/docs/features/blocks#default-block-properties)._
+
+ _Every block can have child blocks, indented below it. You do not declare
+ them in the config. To draw a frame around a block and its child blocks, see
+ [Block Frames](#block-frames). `layoutOnly`, `children` and `placeable` are
+ only for layout blocks. A layout block only holds and arranges other blocks,
+ but has no content of its own, for example a panel or a tab set. See [Layout
+ Blocks](/docs/features/custom-schemas/layout-blocks)._
+
+
### Block Implementation (`ReactCustomBlockImplementation`)
The Block Implementation defines how the block should be rendered in the editor, and how it should be parsed from and converted to HTML.
@@ -121,11 +135,17 @@ type ReactCustomBlockImplementation = {
editor: BlockNoteEditor;
contentRef?: (node: HTMLElement | null) => void;
}>;
+ renderFrame?: React.FC<{
+ block: Block;
+ editor: BlockNoteEditor;
+ slotRef: (node: HTMLElement | null) => void;
+ }>;
toExternalHTML?: React.FC<{
block: Block;
editor: BlockNoteEditor;
contentRef?: (node: HTMLElement | null) => void;
- contest: { nestingLevel: number };
+ childrenRef: (node: HTMLElement | null) => void;
+ context: { nestingLevel: number };
}>;
parse?: (element: HTMLElement) => PartialBlock["props"] | undefined;
parseContent?: (options: {
@@ -133,13 +153,26 @@ 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;
+ // splitKeepsChildren?: 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;
defining?: boolean;
isolating?: boolean;
+ draggable?: boolean;
highlight?: (block: Block) => string | undefined;
};
};
@@ -153,9 +186,14 @@ type ReactCustomBlockImplementation = {
- `contentRef:` A React `ref` you can use to mark which element in your block is editable. This is only available if your block config contains `content: "inline"` or `content: "plain"`.
-`toExternalHTML?:` This component is used whenever the block is being exported to HTML for use outside BlockNote, for example when copying it to the clipboard. If it's not defined, BlockNote will just use `render` for the HTML conversion. Takes the same props as `render` and an additional `context` prop, which is. an object with the following attributes:
+`renderFrame?:` A component that draws a frame around the block's content and its child blocks. See [Block Frames](#block-frames).
+
+`toExternalHTML?:` This component is used whenever the block is being exported to HTML for use outside BlockNote, for example when copying it to the clipboard. If it's not defined, BlockNote will just use `render` (and `renderFrame`, if the block has one) for the HTML conversion. Takes the same props as `render`, and two more:
+
+- `childrenRef:` A React `ref` that marks where the block's child blocks go, for example inside a `` element. Without it, the child blocks follow the block.
-- `nestingLevel`: How deep the block being exported in nested inside other blocks. 0 means it's at the top level of the document.
+- `context:` An object with the following attributes:
+ - `nestingLevel`: How deep the block being exported is nested inside other blocks. 0 means it's at the top level of the document.
_Note that your component passed to `toExternalHTML` is rendered and
@@ -171,9 +209,27 @@ 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"`.
+
+- `splitKeepsType?:` Whether the block created by splitting this one with Enter has the same type, as in lists. Defaults to `false`.
+
+- `splitKeepsChildren?:` Whether the block's children stay with it when Enter splits it. Otherwise they follow the text after the caret into the new block, as for nested blocks, so the new block takes the block's place above them. Defaults to `true` when `enter` is `"into-children"` (the children are the block's body), otherwise `false`; a closed toggle sets it to keep its hidden children.
+
+- `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 layout 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.
-- `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.
+- `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 layout 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.
@@ -185,6 +241,8 @@ type ReactCustomBlockImplementation = {
- `isolating?:` Whether this block is [isolating](https://prosemirror.net/docs/ref/#model.NodeSpec.isolating).
+- `draggable?:` Whether the block gets a side menu drag handle. Defaults to `true`, or `false` for a layout block that can only be placed inside a specific parent (`placeable: "namedOnly"`).
+
- `highlight?:` A callback that returns the language the block's text should be syntax-highlighted as (or `undefined` for no highlighting). Highlighting only takes effect when the [syntax highlighting extension](/docs/features/blocks/code-blocks#syntax-highlighting) is added to the editor.
### Block Extensions
@@ -257,3 +315,86 @@ You can then instantiate your editor with this custom schema, as explained on th
Now you know how to create a custom block and add it to your editor. However, users don't have a way of creating instances of it to their documents.
To fix this, it's recommended to implement a [command to insert your custom in the Slash Menu](/docs/react/components/suggestion-menus#changing-slash-menu-items), and [an item for it in the Block Type Select](/docs/react/components/formatting-toolbar)
+
+## Block Frames
+
+A block can have child blocks. By default, BlockNote shows the child blocks indented below the block. Use `renderFrame` to draw one frame around the block's content and its child blocks, for example one background for both.
+
+In the demo below, we give the Alert block from the demo above a title and a body. The alert's text is its title, and its child blocks are its body. The frame gives the title and the body the background color of the alert type:
+
+
+
+The block config does not change. A block with content (`content: "inline"` or `"plain"`) can always have child blocks, so you do not declare them. We add `renderFrame` and `experimental_keyboard` to the block implementation:
+
+```tsx
+export const createAlert = createReactBlockSpec(
+ {
+ type: "alert",
+ propSchema: {
+ type: {
+ default: "warning",
+ values: ["warning", "error", "info", "success"],
+ },
+ },
+ content: "inline",
+ },
+ {
+ experimental_keyboard: {
+ enter: "into-children",
+ childrenCanOutdent: false,
+ emptyChildEnter: "exit-at-end",
+ },
+ // Draws the title row, the same as in the Alert demo.
+ render: (props) => {
+ ...
+ },
+ renderFrame: (props) => (
+
+
+
+ ),
+ },
+);
+```
+
+`render:` Draws only the block's content, here the alert's title row. It does not change when you add a frame.
+
+`renderFrame:` A React component that draws the frame. It takes three React props:
+
+- `block:` The block. Use its props to change the frame, for example to show the color of the alert type.
+
+- `editor:` The BlockNote editor instance that the block is in.
+
+- `slotRef:` A React `ref`. Attach it to the element that holds the block's content and its child blocks. You can add other elements around this element, for example icons or buttons. Set `contentEditable={false}` on them.
+
+To show the block without a frame, return `null`. For example, you can use a prop to turn the frame on and off.
+
+`experimental_keyboard:` Without these settings, the child blocks behave like all other nested blocks. Here, they keep the body in the alert:
+
+- `enter: "into-children"`: Enter in the title adds a block at the start of the body.
+- `childrenCanOutdent: false`: Shift-Tab does not move blocks out of the alert.
+- `emptyChildEnter: "exit-at-end"`: Enter in an empty last block of the body moves that block out of the alert.
+
+See [`experimental_keyboard`](#block-implementation-reactcustomblockimplementation) above for all settings.
+
+Use `content` for the title and `children` for the body:
+
+```typescript
+{
+ type: "alert",
+ content: "Before you start",
+ children: [
+ { type: "paragraph", content: "Make sure you have an account." },
+ ],
+}
+```
+
+When the user moves the alert, its title and its body move together.
+
+
+ _A block with a frame is different from a [layout
+ block](/docs/features/custom-schemas/layout-blocks). A layout block has no
+ content of its own. It only holds child blocks, for example a panel or a tab
+ set. See [Layout Blocks and Block
+ Frames](/docs/features/custom-schemas/layout-blocks#layout-blocks-and-block-frames)._
+
diff --git a/docs/content/docs/features/custom-schemas/layout-blocks.mdx b/docs/content/docs/features/custom-schemas/layout-blocks.mdx
new file mode 100644
index 0000000000..12d8ef83a5
--- /dev/null
+++ b/docs/content/docs/features/custom-schemas/layout-blocks.mdx
@@ -0,0 +1,177 @@
+---
+title: Layout Blocks
+description: Learn how to create custom blocks that contain other blocks
+---
+
+# Layout Blocks
+
+
+ Layout blocks are a preview feature. The API can change in a future release.
+ We would like to hear what you build with layout blocks, and what does not
+ work for you. Please tell us in [GitHub
+ Discussions](https://github.com/TypeCellOS/BlockNote/discussions) or open an
+ [issue](https://github.com/TypeCellOS/BlockNote/issues).
+
+
+A layout block is a custom block that holds and arranges other blocks, but has no content of its own. Panels, tab sets and column layouts are examples. Its child blocks are inside the layout block, not indented below it, and the layout block draws a frame around them. A layout block can also limit which blocks it holds, for example only tabs in a tab set.
+
+If your block has its own text and also child blocks, for example an alert with a title and a body, use a custom block with a [frame](/docs/features/custom-schemas/custom-blocks#block-frames). See [Layout Blocks and Block Frames](#layout-blocks-and-block-frames) for the differences.
+
+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.
+
+
+
+For more advanced layout blocks, see the [Tabs Block](/examples/custom-schema/tabs-block) and [Stepper Block](/examples/custom-schema/stepper-block) examples.
+
+## Creating a Layout Block
+
+Use the `createReactBlockSpec` function to create a layout block, just like a [Custom Block](/docs/features/custom-schemas/custom-blocks). For the panel below, we set `content` to `"none"` and `layoutOnly` 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",
+ layoutOnly: true,
+ },
+ {
+ renderFrame: (props) => (
+
+ ),
+ },
+);
+```
+
+### Block Config
+
+The block config defines the content and child blocks your layout block can hold:
+
+`content:` Must be `"none"` for a layout block. A layout block holds only its child blocks. For a block with its own text and child blocks, see [Layout Blocks and Block Frames](#layout-blocks-and-block-frames).
+
+`layoutOnly:` Set to `true` to put the block's child blocks inside it. Without it, a block's child blocks are indented below it. A layout block accepts any block by default. You can also restrict it to specific layout block types, as explained in [Restricting Children](#restricting-children).
+
+`propSchema:` Defines the layout block's props, just like for other custom blocks. Use these to customize its appearance or behavior.
+
+### Block Implementation
+
+`renderFrame:` Your React component draws the layout block's frame. A layout block has no content of its own, so it has no `render`, only this frame. `renderFrame` takes the same props as for [other blocks](/docs/features/custom-schemas/custom-blocks#block-frames), but it must always draw the frame: it must not return `null`. Attach `slotRef` to the element that holds the child blocks. You can add icons, buttons, or other elements around this element:
+
+```tsx
+renderFrame: (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 Layout Blocks to the Editor
+
+Add your layout block 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.
+
+To add blocks to an existing layout block, see [Inserting Blocks](/docs/reference/editor/manipulating-content#inserting-blocks).
+
+## Layout Blocks and Block Frames
+
+Every custom block can have a [frame](/docs/features/custom-schemas/custom-blocks#block-frames), drawn by `renderFrame` around the block's content and its child blocks. A layout block is a block that has only a frame. The table shows the differences:
+
+| | Block with a frame | Layout block |
+| --- | --- | --- |
+| Example | [Alert with a title and a body](/examples/custom-schema/alert-block-with-title-and-body) | [Panel](/examples/custom-schema/layout-block), [tab set](/examples/custom-schema/tabs-block), [stepper](/examples/custom-schema/stepper-block), [column list](/examples/basic/multi-column) |
+| Config | Any `content`, usually `"inline"` or `"plain"` | `content: "none"` and `layoutOnly: true` |
+| Content | Its own text, drawn by `render` | None |
+| Implementation | `render` (mandatory) draws the content. `renderFrame` (optional) draws the frame. It can return `null` to show the block without a frame | `renderFrame` (mandatory) draws the frame. It must always draw a frame: if it returns `null`, export fails |
+| Child blocks | Any blocks | Any blocks, or only the layout block types in [`children.allow`](#restricting-children) |
+
+Use a layout block when the block only holds and arranges other blocks. Use a block with a frame when the block has its own text, for example a title.
+
+## Restricting Children
+
+For structured layouts, you can limit a layout block to specific layout block types, with these options in the block config:
+
+`children.allow:` Accepts `"blocks"` (the default) or an array of layout block type names. At this moment, you cannot list regular block types such as `"paragraph"` individually.
+
+`children.min:` The minimum number of children, for an `allow` list only. Defaults to `1`.
+
+`children.dissolve:` Whether a layout block with too few children dissolves into what they hold, for an `allow` list only. Defaults to `true`, like a column list with one column left. Set `false` to keep the layout block and its empty children, like a tab set.
+
+`children.exclude:` For a layout block that accepts any blocks, the layout block types it does not hold. Only layout block types can be excluded, not regular block types such as `"paragraph"`.
+
+`placeable:` Set to `"namedOnly"` to restrict a layout block to parents that name it in `children.allow`. Defaults to `"anywhere"`.
+
+The [Tabs Block](/examples/custom-schema/tabs-block) and [Stepper Block](/examples/custom-schema/stepper-block) examples use these options. A tab set holds only `tab` blocks, and a stepper holds only `step` blocks. Both set `dissolve: false`, so they stay when the user removes their last tab or step. A `tab` and a `step` are `placeable: "namedOnly"`, so they can only be in a tab set or a stepper.
+
+These options need `layoutOnly: 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).
+
+## Exporting Layout Blocks
+
+To export a document with your layout block to [DOCX](/docs/features/export/docx), [ODT](/docs/features/export/odt), [PDF](/docs/features/export/pdf), [Typst](/docs/features/export/typst) or [email](/docs/features/export/email), give the exporter a mapping for the block. The mapping for a layout block must be a `{ withChildren }` mapping. A function mapping causes an error, because the exporter does not know where the child blocks go.
+
+`withChildren` gets the child blocks, which the exporter has already converted. Put them inside the result for the block. For example, for Typst:
+
+```typescript
+new TypstExporter(schema, {
+ ...typstDefaultSchemaMappings,
+ blockMapping: {
+ ...typstDefaultSchemaMappings.blockMapping,
+ panel: {
+ withChildren: (block, exporter, nestingLevel, numberedListIndex, children) =>
+ // Draws a box around the child blocks. Each child block is Typst
+ // markup, and a blank line keeps them as separate blocks.
+ `#block(stroke: 1pt, inset: 8pt)[${children.join("\n\n")}]`,
+ },
+ },
+});
+```
+
+The other exporters work the same way, but give `children` in their own format, for example `docx` elements for DOCX.
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..fecee10e45 100644
--- a/docs/content/docs/features/export/typst.mdx
+++ b/docs/content/docs/features/export/typst.mdx
@@ -120,6 +120,11 @@ 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).
+A `{ withChildren }` mapping places the block's children itself
+([layout blocks](/docs/features/custom-schemas/layout-blocks) must use
+one). It gets the children as rendered markup strings: join them with a blank
+line, `children.join("\n\n")`, so each stays its own block, since 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..3bb3091225 100644
--- a/docs/content/docs/reference/editor/manipulating-content.mdx
+++ b/docs/content/docs/reference/editor/manipulating-content.mdx
@@ -173,6 +173,11 @@ editor.insertBlocks(
);
```
+`placement` says where the new blocks go, relative to the reference block:
+
+- `"before"` and `"after"`: as its siblings, directly before or after it.
+- `"first-child"` and `"last-child"`: as its children, at the start or at the end. The reference block does not need to have children yet.
+
### Updating Blocks
#### Modifying Existing Blocks
diff --git a/docs/package.json b/docs/package.json
index 2fdeefb332..5de539654c 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -113,7 +113,10 @@
"yjs": "^13.6.27",
"zod": "^4.3.5",
"@blocknote/xl-typst-exporter": "workspace:*",
- "@blocknote/xl-typst-compiler": "workspace:*"
+ "@blocknote/xl-typst-compiler": "workspace:*",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2"
},
"devDependencies": {
"@blocknote/code-block": "workspace:*",
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/.bnexample.json b/examples/06-custom-schema/01-alert-block/.bnexample.json
index 1354e61187..3dd6aed529 100644
--- a/examples/06-custom-schema/01-alert-block/.bnexample.json
+++ b/examples/06-custom-schema/01-alert-block/.bnexample.json
@@ -2,13 +2,7 @@
"playground": true,
"docs": true,
"author": "matthewlipski",
- "tags": [
- "Intermediate",
- "Blocks",
- "Custom Schemas",
- "Suggestion Menus",
- "Slash Menu"
- ],
+ "tags": ["Intermediate", "Blocks", "Custom Schemas"],
"dependencies": {
"@mantine/core": "^9.0.2",
"react-icons": "^5.5.0"
diff --git a/examples/06-custom-schema/01-alert-block/README.md b/examples/06-custom-schema/01-alert-block/README.md
index 831ca1993b..a8e5d6286d 100644
--- a/examples/06-custom-schema/01-alert-block/README.md
+++ b/examples/06-custom-schema/01-alert-block/README.md
@@ -6,5 +6,7 @@ In this example, we create a custom `Alert` block which is used to emphasize tex
**Relevant Docs:**
+- [Alert Block with Full UX](/examples/custom-schema/alert-block-full-ux)
+- [Alert Block with Title and Body](/examples/custom-schema/alert-block-with-title-and-body)
- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)
- [Editor Setup](/docs/getting-started/editor-setup)
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/README.md b/examples/06-custom-schema/05-alert-block-full-ux/README.md
index 53368fc687..40dc1d5d73 100644
--- a/examples/06-custom-schema/05-alert-block-full-ux/README.md
+++ b/examples/06-custom-schema/05-alert-block-full-ux/README.md
@@ -7,6 +7,7 @@ In this example, we create a custom `Alert` block which is used to emphasize tex
**Relevant Docs:**
- [Minimal Alert Block Example](/examples/custom-schema/alert-block)
+- [Alert Block with Title and Body](/examples/custom-schema/alert-block-with-title-and-body)
- [Changing Slash Menu Items](/docs/react/components/suggestion-menus)
- [Changing Block Type Select Items](/docs/react/components/formatting-toolbar)
- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)
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/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-layout-block/.bnexample.json b/examples/06-custom-schema/09-layout-block/.bnexample.json
new file mode 100644
index 0000000000..3de7330631
--- /dev/null
+++ b/examples/06-custom-schema/09-layout-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-layout-block/README.md b/examples/06-custom-schema/09-layout-block/README.md
new file mode 100644
index 0000000000..3ed1f82753
--- /dev/null
+++ b/examples/06-custom-schema/09-layout-block/README.md
@@ -0,0 +1,18 @@
+# Layout 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 `layoutOnly: true` on `BlockConfig`, which makes it a layout block: it has no content of its own, only child blocks. It draws its box in `renderFrame`, and its child blocks go where `slotRef` is attached. At runtime, they are in `block.children`.
+
+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:**
+
+- [Layout Blocks](/docs/features/custom-schemas/layout-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-layout-block/index.html
similarity index 89%
rename from examples/06-custom-schema/06-toggleable-blocks/index.html
rename to examples/06-custom-schema/09-layout-block/index.html
index bdac14de54..29dafcdc28 100644
--- a/examples/06-custom-schema/06-toggleable-blocks/index.html
+++ b/examples/06-custom-schema/09-layout-block/index.html
@@ -5,7 +5,7 @@
name="viewport"
content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content"
/>
- Toggleable Custom Blocks
+ Layout Block
diff --git a/examples/06-custom-schema/06-toggleable-blocks/main.tsx b/examples/06-custom-schema/09-layout-block/main.tsx
similarity index 100%
rename from examples/06-custom-schema/06-toggleable-blocks/main.tsx
rename to examples/06-custom-schema/09-layout-block/main.tsx
diff --git a/examples/06-custom-schema/06-toggleable-blocks/package.json b/examples/06-custom-schema/09-layout-block/package.json
similarity index 85%
rename from examples/06-custom-schema/06-toggleable-blocks/package.json
rename to examples/06-custom-schema/09-layout-block/package.json
index caed51c711..374c79e5b0 100644
--- a/examples/06-custom-schema/06-toggleable-blocks/package.json
+++ b/examples/06-custom-schema/09-layout-block/package.json
@@ -1,5 +1,5 @@
{
- "name": "@blocknote/example-custom-schema-toggleable-blocks",
+ "name": "@blocknote/example-custom-schema-layout-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/09-layout-block/src/App.tsx b/examples/06-custom-schema/09-layout-block/src/App.tsx
new file mode 100644
index 0000000000..9d48fd0064
--- /dev/null
+++ b/examples/06-custom-schema/09-layout-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 layout 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: a layout block always holds at least one child.
+function insertPanel(editor: typeof schema.BlockNoteEditor) {
+ return {
+ title: "Panel",
+ subtext: "Layout block that wraps other blocks",
+ onItemClick: () =>
+ insertOrUpdateBlockForSlashMenu(editor, {
+ type: "panel",
+ }),
+ aliases: ["panel", "layout", "container", "box", "group", "section"],
+ group: "Layout",
+ icon: ,
+ };
+}
+
+export default function App() {
+ const editor = useCreateBlockNote({
+ schema,
+ initialContent: [
+ {
+ type: "paragraph",
+ content: "Welcome! This demo shows the new layout 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-layout-block/src/Panel.tsx b/examples/06-custom-schema/09-layout-block/src/Panel.tsx
new file mode 100644
index 0000000000..58c093c34f
--- /dev/null
+++ b/examples/06-custom-schema/09-layout-block/src/Panel.tsx
@@ -0,0 +1,17 @@
+import { createReactBlockSpec } from "@blocknote/react";
+
+import "./styles.css";
+
+export const createPanel = createReactBlockSpec(
+ {
+ type: "panel",
+ propSchema: {},
+ content: "none",
+ layoutOnly: true,
+ },
+ {
+ // With no content of its own, the panel draws only its frame. The slot
+ // (`slotRef`) receives its child blocks.
+ renderFrame: (props) => ,
+ },
+);
diff --git a/examples/06-custom-schema/09-layout-block/src/styles.css b/examples/06-custom-schema/09-layout-block/src/styles.css
new file mode 100644
index 0000000000..6f3837c013
--- /dev/null
+++ b/examples/06-custom-schema/09-layout-block/src/styles.css
@@ -0,0 +1,11 @@
+/* A neutral box: the panel only groups blocks, it has no meaning of its own
+ (compare the Alert block, whose box shows its type). */
+.panel {
+ border: 1px solid var(--panel-border, #d4d4d8);
+ border-radius: 6px;
+ padding: 12px 16px;
+}
+
+[data-color-scheme="dark"] .panel {
+ --panel-border: #3f3f46;
+}
diff --git a/examples/06-custom-schema/06-toggleable-blocks/tsconfig.json b/examples/06-custom-schema/09-layout-block/tsconfig.json
similarity index 100%
rename from examples/06-custom-schema/06-toggleable-blocks/tsconfig.json
rename to examples/06-custom-schema/09-layout-block/tsconfig.json
diff --git a/examples/06-custom-schema/06-toggleable-blocks/vite-env.d.ts b/examples/06-custom-schema/09-layout-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-layout-block/vite-env.d.ts
diff --git a/examples/06-custom-schema/06-toggleable-blocks/vite.config.ts b/examples/06-custom-schema/09-layout-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-layout-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-layout-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-alert-block-with-title-and-body/.bnexample.json b/examples/06-custom-schema/13-alert-block-with-title-and-body/.bnexample.json
new file mode 100644
index 0000000000..467ebd02e5
--- /dev/null
+++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/.bnexample.json
@@ -0,0 +1,10 @@
+{
+ "playground": true,
+ "docs": true,
+ "author": "nickthesick",
+ "tags": ["Intermediate", "Blocks", "Custom Schemas"],
+ "dependencies": {
+ "@mantine/core": "^9.0.2",
+ "react-icons": "^5.5.0"
+ }
+}
diff --git a/examples/06-custom-schema/13-alert-block-with-title-and-body/README.md b/examples/06-custom-schema/13-alert-block-with-title-and-body/README.md
new file mode 100644
index 0000000000..57ea2eb5e9
--- /dev/null
+++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/README.md
@@ -0,0 +1,22 @@
+# Alert Block with Title and Body
+
+In this example, we extend the [`Alert` block](/examples/custom-schema/alert-block) with a title and a body: the alert's text is its title, and its child blocks are the body.
+
+The block has `content: "inline"`, so the title is ordinary inline content: formatting, links, and multiplayer cursors all work. Its child blocks are in `block.children` at runtime. `render` draws the title row, the same as in the Alert example, and `renderFrame` draws the alert's box around the title and the body together. Its `experimental_keyboard` settings keep the body inside the alert: 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 alert.
+
+**Try it out:**
+
+- Click the icon to change the alert type.
+- Press Enter at the end of the alert's title to add a block to its body.
+- Press Enter in an empty last block of the body to leave the alert.
+- Press Backspace at the start of the first block in the body to merge it back into the title.
+- Press "/" in the body and add a code block, heading, or list.
+
+**Relevant Docs:**
+
+- [Minimal Alert Block Example](/examples/custom-schema/alert-block)
+- [Alert Block with Full UX](/examples/custom-schema/alert-block-full-ux)
+- [Block Frames](/docs/features/custom-schemas/custom-blocks#block-frames)
+- [Layout Blocks and Block Frames](/docs/features/custom-schemas/layout-blocks#layout-blocks-and-block-frames)
+- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)
+- [Editor Setup](/docs/getting-started/editor-setup)
diff --git a/examples/06-custom-schema/13-alert-block-with-title-and-body/index.html b/examples/06-custom-schema/13-alert-block-with-title-and-body/index.html
new file mode 100644
index 0000000000..1b8395fdb7
--- /dev/null
+++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+ Alert Block with Title and Body
+
+
+
+
+
+
+
diff --git a/examples/06-custom-schema/13-alert-block-with-title-and-body/main.tsx b/examples/06-custom-schema/13-alert-block-with-title-and-body/main.tsx
new file mode 100644
index 0000000000..1260513388
--- /dev/null
+++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/main.tsx
@@ -0,0 +1,11 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./src/App.jsx";
+
+const root = createRoot(document.getElementById("root")!);
+root.render(
+
+
+ ,
+);
diff --git a/examples/06-custom-schema/13-alert-block-with-title-and-body/package.json b/examples/06-custom-schema/13-alert-block-with-title-and-body/package.json
new file mode 100644
index 0000000000..6a60f32370
--- /dev/null
+++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@blocknote/example-custom-schema-alert-block-with-title-and-body",
+ "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "type": "module",
+ "private": true,
+ "version": "0.12.4",
+ "scripts": {
+ "start": "vite",
+ "dev": "vite",
+ "build:prod": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@blocknote/ariakit": "latest",
+ "@blocknote/core": "latest",
+ "@blocknote/mantine": "latest",
+ "@blocknote/react": "latest",
+ "@blocknote/shadcn": "latest",
+ "@mantine/core": "^9.0.2",
+ "@mantine/hooks": "^9.0.2",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3",
+ "react-icons": "^5.5.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.3",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "vite": "^8.0.0"
+ }
+}
diff --git a/examples/06-custom-schema/13-alert-block-with-title-and-body/src/Alert.tsx b/examples/06-custom-schema/13-alert-block-with-title-and-body/src/Alert.tsx
new file mode 100644
index 0000000000..800b30d45a
--- /dev/null
+++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/src/Alert.tsx
@@ -0,0 +1,135 @@
+import { defaultProps } from "@blocknote/core";
+import { createReactBlockSpec } from "@blocknote/react";
+import { Menu } from "@mantine/core";
+import { MdCancel, MdCheckCircle, MdError, MdInfo } from "react-icons/md";
+
+import "./styles.css";
+
+// The types of alerts that users can choose from.
+export const alertTypes = [
+ {
+ title: "Warning",
+ value: "warning",
+ icon: MdError,
+ color: "#e69819",
+ backgroundColor: {
+ light: "#fff6e6",
+ dark: "#805d20",
+ },
+ },
+ {
+ title: "Error",
+ value: "error",
+ icon: MdCancel,
+ color: "#d80d0d",
+ backgroundColor: {
+ light: "#ffe6e6",
+ dark: "#802020",
+ },
+ },
+ {
+ title: "Info",
+ value: "info",
+ icon: MdInfo,
+ color: "#507aff",
+ backgroundColor: {
+ light: "#e6ebff",
+ dark: "#203380",
+ },
+ },
+ {
+ title: "Success",
+ value: "success",
+ icon: MdCheckCircle,
+ color: "#0bc10b",
+ backgroundColor: {
+ light: "#e6ffe6",
+ dark: "#208020",
+ },
+ },
+] as const;
+
+// The Alert block, with a title (its text) and a body (its child blocks).
+export const createAlert = createReactBlockSpec(
+ {
+ type: "alert",
+ propSchema: {
+ textAlignment: defaultProps.textAlignment,
+ textColor: defaultProps.textColor,
+ type: {
+ default: "warning",
+ values: ["warning", "error", "info", "success"],
+ },
+ },
+ content: "inline",
+ },
+ {
+ // Keeps the body inside the alert: Enter in the title adds a first child
+ // block, child blocks can't be outdented out of the alert, and Enter in an
+ // empty last child block leaves the alert.
+ experimental_keyboard: {
+ enter: "into-children",
+ childrenCanOutdent: false,
+ emptyChildEnter: "exit-at-end",
+ },
+ // The title row. The child blocks (the body) go below it.
+ render: (props) => {
+ const alertType = alertTypes.find(
+ (a) => a.value === props.block.props.type,
+ )!;
+ const Icon = alertType.icon;
+ return (
+
+ {/*Icon which opens a menu to choose the Alert type*/}
+
+ {/*Rich text field for user to type in*/}
+
+
+ );
+ },
+ // The alert's box, around the title row and the body.
+ renderFrame: (props) => (
+
+
+
+ ),
+ },
+);
diff --git a/examples/06-custom-schema/06-toggleable-blocks/src/App.tsx b/examples/06-custom-schema/13-alert-block-with-title-and-body/src/App.tsx
similarity index 53%
rename from examples/06-custom-schema/06-toggleable-blocks/src/App.tsx
rename to examples/06-custom-schema/13-alert-block-with-title-and-body/src/App.tsx
index 81877de7e4..52d4b5f6f3 100644
--- a/examples/06-custom-schema/06-toggleable-blocks/src/App.tsx
+++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/src/App.tsx
@@ -1,19 +1,17 @@
-import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core";
+import { BlockNoteSchema } 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";
+import { createAlert } from "./Alert";
// Our schema with block specs, which contain the configs and implementations for
// blocks that we want our editor to use.
-const schema = BlockNoteSchema.create({
+const schema = BlockNoteSchema.create().extend({
blockSpecs: {
- // Adds all default blocks.
- ...defaultBlockSpecs,
- // Adds the Toggle block.
- toggle: ToggleBlock(),
+ // Creates an instance of the Alert block with a body and adds it to the schema.
+ alert: createAlert(),
},
});
@@ -27,25 +25,18 @@ export default function App() {
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",
+ type: "alert",
+ content: "This is an example alert",
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.",
+ content: "This is the body of the alert",
},
],
},
{
type: "paragraph",
- content: "Click the '>' icon to show/hide its children",
+ content: "Click the '!' icon to change the alert type",
},
],
});
diff --git a/examples/06-custom-schema/13-alert-block-with-title-and-body/src/styles.css b/examples/06-custom-schema/13-alert-block-with-title-and-body/src/styles.css
new file mode 100644
index 0000000000..ed2d871ea2
--- /dev/null
+++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/src/styles.css
@@ -0,0 +1,89 @@
+.alert-frame {
+ border-radius: 4px;
+ padding: 4px;
+}
+
+.alert-title-row {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-grow: 1;
+ min-height: 48px;
+}
+
+.alert-frame[data-alert-type="warning"] {
+ background-color: #fff6e6;
+}
+
+.alert-frame[data-alert-type="error"] {
+ background-color: #ffe6e6;
+}
+
+.alert-frame[data-alert-type="info"] {
+ background-color: #e6ebff;
+}
+
+.alert-frame[data-alert-type="success"] {
+ background-color: #e6ffe6;
+}
+
+[data-color-scheme="dark"] .alert-frame[data-alert-type="warning"] {
+ background-color: #805d20;
+}
+
+[data-color-scheme="dark"] .alert-frame[data-alert-type="error"] {
+ background-color: #802020;
+}
+
+[data-color-scheme="dark"] .alert-frame[data-alert-type="info"] {
+ background-color: #203380;
+}
+
+[data-color-scheme="dark"] .alert-frame[data-alert-type="success"] {
+ background-color: #208020;
+}
+
+.alert-icon-wrapper {
+ border-radius: 16px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-left: 12px;
+ margin-right: 12px;
+ height: 18px;
+ width: 18px;
+ user-select: none;
+ cursor: pointer;
+}
+
+.alert-icon[data-alert-icon-type="warning"] {
+ color: #e69819;
+}
+
+.alert-icon[data-alert-icon-type="error"] {
+ color: #d80d0d;
+}
+
+.alert-icon[data-alert-icon-type="info"] {
+ color: #507aff;
+}
+
+.alert-icon[data-alert-icon-type="success"] {
+ color: #0bc10b;
+}
+
+.inline-content {
+ flex-grow: 1;
+}
+
+.alert-title-row .inline-content {
+ font-weight: 600;
+}
+
+/* Hides the indent line next to the blocks of the alert's body. Blocks that
+ are nested deeper in the body keep their line. */
+.alert-frame
+ .bn-block-group:not(.alert-frame .bn-block-group .bn-block-group)
+ > .bn-block-outer:not([data-prev-depth-changed])::before {
+ border-left: none;
+}
diff --git a/examples/06-custom-schema/13-alert-block-with-title-and-body/tsconfig.json b/examples/06-custom-schema/13-alert-block-with-title-and-body/tsconfig.json
new file mode 100644
index 0000000000..2aa62c56e6
--- /dev/null
+++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/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-alert-block-with-title-and-body/vite-env.d.ts b/examples/06-custom-schema/13-alert-block-with-title-and-body/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/06-custom-schema/13-alert-block-with-title-and-body/vite.config.ts b/examples/06-custom-schema/13-alert-block-with-title-and-body/vite.config.ts
new file mode 100644
index 0000000000..cbf6ff2ffc
--- /dev/null
+++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/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/14-tabs-block/.bnexample.json b/examples/06-custom-schema/14-tabs-block/.bnexample.json
new file mode 100644
index 0000000000..c8a8e4711b
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/.bnexample.json
@@ -0,0 +1,12 @@
+{
+ "playground": true,
+ "docs": true,
+ "author": "yousefed",
+ "tags": ["Intermediate", "Blocks", "Custom Schemas"],
+ "dependencies": {
+ "react-icons": "^5.5.0",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2"
+ }
+}
diff --git a/examples/06-custom-schema/14-tabs-block/README.md b/examples/06-custom-schema/14-tabs-block/README.md
new file mode 100644
index 0000000000..6b45d10800
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/README.md
@@ -0,0 +1,46 @@
+# Tabs Block
+
+A tab set built on the layout block API. `tabs` is a layout block whose
+`children` are restricted to `tab` panels, and `tab` is a `placeable:
+"namedOnly"` layout block, so a panel can only ever exist inside a tab set —
+the schema enforces both. Both blocks are layout blocks (`layoutOnly: true`), so
+they declare only `renderFrame`, and the child blocks go where `slotRef` is
+attached.
+
+Each panel holds any block. A panel's label is document content and lives in
+its props. Which panel is open is not: it belongs to each reader, so it is kept
+outside the document, in `localStorage` keyed by the tab set's id. Switching
+tabs is therefore not an undo step and is not sent to collaborators.
+
+**Try it out:** Click a tab to open it, click the open tab for its menu, and
+drag a tab to reorder it.
+
+## What a tab set needs beyond the layout block API
+
+**Revealing the panel the caret lands in.** A hidden panel is still part of the
+document, so the editor will move content into it: Backspace at the start of
+the block after a tab set pulls that block into the last panel, which may not
+be the open one. `useRevealCaretPanel` opens whichever panel the caret ends up
+in, using `editor.onSelectionChange`, so every route in (Backspace, Delete,
+arrow keys, drag and drop, paste) is covered at once.
+
+**Moving the caret when a tab is clicked.** An explicit switch takes the caret
+with it when the caret was inside the set. Otherwise the reveal above would
+immediately reopen the panel it was left in.
+
+**A menu instead of buttons.** Clicking the open tab opens a menu to rename,
+move or delete it. It is built from `useComponentsContext()`, so it matches
+whichever UI library the editor uses.
+
+**Drag to reorder.** Tabs are sortable with dnd-kit. A tab keeps its block id
+when it moves, so the reader's open tab follows it.
+
+**Empty tabs stay.** The tab set sets `children.dissolve` to `false`. A tab
+is content, not layout: an empty tab is still a tab, and when the last tab is
+removed, the tab set stays with a new, empty tab. (A column list dissolves:
+an empty column is removed, and one column left is just its blocks.)
+
+**Relevant Docs:**
+
+- [Layout Blocks](/docs/features/custom-schemas/layout-blocks)
+- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)
diff --git a/examples/06-custom-schema/14-tabs-block/index.html b/examples/06-custom-schema/14-tabs-block/index.html
new file mode 100644
index 0000000000..46ea6b3c71
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+ Tabs Block
+
+
+
+
+
+
+
diff --git a/examples/06-custom-schema/14-tabs-block/main.tsx b/examples/06-custom-schema/14-tabs-block/main.tsx
new file mode 100644
index 0000000000..1260513388
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-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/14-tabs-block/package.json b/examples/06-custom-schema/14-tabs-block/package.json
new file mode 100644
index 0000000000..de4c50d3c5
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@blocknote/example-custom-schema-tabs-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",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2"
+ },
+ "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/14-tabs-block/src/App.tsx b/examples/06-custom-schema/14-tabs-block/src/App.tsx
new file mode 100644
index 0000000000..8e21f96f9d
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/src/App.tsx
@@ -0,0 +1,110 @@
+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 { RiLayoutTopLine } from "react-icons/ri";
+
+import { createTab, createTabs } from "./Tabs";
+import "./styles.css";
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ tabs: createTabs(),
+ tab: createTab(),
+ },
+});
+
+// A tab set is only useful with its panels, so the slash menu inserts both.
+function insertTabs(editor: typeof schema.BlockNoteEditor) {
+ return {
+ title: "Tabs",
+ subtext: "A tab strip with one panel per tab",
+ onItemClick: () =>
+ insertOrUpdateBlockForSlashMenu(editor, {
+ type: "tabs",
+ children: [
+ {
+ type: "tab",
+ props: { label: "First" },
+ children: [{ type: "paragraph", content: "First panel" }],
+ },
+ {
+ type: "tab",
+ props: { label: "Second" },
+ children: [{ type: "paragraph", content: "Second panel" }],
+ },
+ ],
+ } as any),
+ aliases: ["tabs", "tab"],
+ group: "Layout",
+ icon: ,
+ };
+}
+
+export default function App() {
+ const editor = useCreateBlockNote({
+ schema,
+ initialContent: [
+ {
+ type: "paragraph",
+ content:
+ "Tabs built on the layout block API. Each panel holds any block.",
+ },
+ {
+ // A persistent ID so the open tab is remembered across reloads: the
+ // choice is stored per tab-set id, and a fresh document would
+ // otherwise get a fresh one.
+ id: "tabs-demo",
+ type: "tabs",
+ children: [
+ {
+ // Persistent IDs alongside the set's, so the remembered choice
+ // still names a panel after a reload.
+ id: "tab-install",
+ type: "tab",
+ props: { label: "Install" },
+ children: [
+ { type: "heading", props: { level: 3 }, content: "Install" },
+ { type: "paragraph", content: "Run the installer, then reboot." },
+ { type: "bulletListItem", content: "Any block works in here" },
+ ],
+ },
+ {
+ id: "tab-configure",
+ type: "tab",
+ props: { label: "Configure" },
+ children: [
+ { type: "heading", props: { level: 3 }, content: "Configure" },
+ { type: "paragraph", content: "Edit the config file." },
+ ],
+ },
+ ],
+ } as any,
+ { type: "paragraph", content: "Press '/' to insert another tab set." },
+ { type: "paragraph" },
+ ],
+ });
+
+ return (
+
+
+ filterSuggestionItems(
+ [...getDefaultReactSlashMenuItems(editor), insertTabs(editor)],
+ query,
+ )
+ }
+ />
+
+ );
+}
diff --git a/examples/06-custom-schema/14-tabs-block/src/Tabs.tsx b/examples/06-custom-schema/14-tabs-block/src/Tabs.tsx
new file mode 100644
index 0000000000..b5105a17bf
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/src/Tabs.tsx
@@ -0,0 +1,518 @@
+import {
+ DndContext,
+ PointerSensor,
+ closestCenter,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ horizontalListSortingStrategy,
+ useSortable,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import type { Block, BlockNoteEditor } from "@blocknote/core";
+import {
+ createReactBlockSpec,
+ useComponentsContext,
+ usePortalElement,
+} from "@blocknote/react";
+import type React from "react";
+import {
+ RiArrowLeftLine,
+ RiArrowRightLine,
+ RiDeleteBinLine,
+ RiPencilLine,
+} from "react-icons/ri";
+import { useEffect, useState, useSyncExternalStore } from "react";
+
+import "./styles.css";
+
+// Which tab is open is *view state*, not content: it belongs to the reader,
+// not to the document. Block props are part of the document, so putting it
+// there would sync it to every collaborator and add an undo step for a
+// glance. BlockNote's own toggle blocks keep their state out of the document
+// the same way, in `localStorage` keyed by block id.
+//
+// Nothing is stored until the reader picks a tab: with no entry, the first
+// panel is the open one. The strip and the panels render in separate React
+// roots, which is why this is a store rather than a context.
+const openTabs = new Map();
+const listeners = new Set<() => void>();
+
+function storageKey(tabsId: string) {
+ return `tabs-open-${tabsId}`;
+}
+
+/** The panel the reader opened in this set, if they have opened one. */
+function openTabId(tabsId: string): string | undefined {
+ const known = openTabs.get(tabsId);
+ if (known !== undefined) {
+ return known;
+ }
+ try {
+ return window.localStorage.getItem(storageKey(tabsId)) ?? undefined;
+ } catch {
+ // Private windows and blocked site data: fall back to the first panel.
+ return undefined;
+ }
+}
+
+function openTab(tabsId: string, tabId: string) {
+ openTabs.set(tabsId, tabId);
+ try {
+ window.localStorage.setItem(storageKey(tabsId), tabId);
+ } catch {
+ // Not being able to remember the choice is not worth failing over.
+ }
+ listeners.forEach((listener) => listener());
+}
+
+/**
+ * The panel that is open in this set: the reader's choice when they have made
+ * one that still names a panel, and the first panel otherwise.
+ */
+function useActiveTabId(tabsId: string, tabs: Block[]): string {
+ const chosen = useSyncExternalStore(
+ (listener) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ () => openTabId(tabsId),
+ () => undefined,
+ );
+
+ // `tabs` is empty only while a panel that was just removed renders one last
+ // time (see `TabPanel`); then no panel is open. Otherwise the schema gives
+ // the set at least one panel (`min: 1`).
+ return tabs.find((tab) => tab.id === chosen)?.id ?? tabs[0]?.id ?? "";
+}
+
+/**
+ * The panels of the tab set being rendered, read back from the editor.
+ *
+ * `props.block` is the snapshot the render closed over, so a handler that has
+ * just inserted or removed a panel cannot see its own change there. The set
+ * is on screen, so it not being in the document means this component outlived
+ * its block, which is a bug rather than a state to render around.
+ */
+function panelsOf(editor: BlockNoteEditor, tabsId: string) {
+ const tabs = editor.getBlock(tabsId);
+ if (!tabs) {
+ throw new Error(`Tab set "${tabsId}" is not in the document`);
+ }
+ return tabs.children;
+}
+
+/** Whether `block` is, or contains, the block with `id`. */
+function contains(block: Block, id: string): boolean {
+ return block.id === id || block.children.some((child) => contains(child, id));
+}
+
+/**
+ * Opens whichever panel the caret ends up in.
+ *
+ * A hidden panel is still part of the document, so the editor will happily
+ * move content into it - Backspace at the start of the block *after* a tab
+ * set pulls that block into the last panel, which may not be the open one.
+ * Without this the block simply disappears from view. Reacting to the
+ * selection covers every route in (Backspace, Delete, arrow keys, drag and
+ * drop, paste) instead of patching one key at a time.
+ */
+function useRevealCaretPanel(
+ editor: BlockNoteEditor,
+ tabsId: string,
+) {
+ useEffect(
+ () =>
+ editor.onSelectionChange(() => {
+ // Read the panels back from the editor rather than closing over
+ // them: the block that just moved into one is not in the list this
+ // effect was created with. A collaborator can remove the set
+ // between the selection changing and this running, so a missing one
+ // is expected here, unlike in the handlers below.
+ const tabs = editor.getBlock(tabsId)?.children;
+ if (!tabs) {
+ return;
+ }
+ const here = editor.getTextCursorPosition().block;
+ const panel = tabs.find((tab) => contains(tab, here.id));
+ if (panel && openTabId(tabsId) !== panel.id) {
+ openTab(tabsId, panel.id);
+ }
+ }),
+ [editor, tabsId],
+ );
+}
+
+/**
+ * A tab panel. It only ever exists inside a `tabs` block, so it declares
+ * `placeable: "namedOnly"` — the schema then keeps it out of every other
+ * position, and BlockNote dissolves it into its blocks if it is moved out.
+ *
+ * Its props carry the label only. Which panel is open is the reader's own
+ * state, so it is held outside the document (see the store above) - a block
+ * prop would travel to every collaborator and cost an undo step.
+ */
+export const createTab = createReactBlockSpec(
+ {
+ type: "tab",
+ propSchema: {
+ label: { default: "Tab" },
+ },
+ content: "none",
+ layoutOnly: true,
+ placeable: "namedOnly",
+ },
+ {
+ // A layout block has no content of its own, so it draws only its frame. The
+ // panel reads its own open state, so it re-renders when the reader
+ // switches tabs without the document changing at all.
+ renderFrame: function TabPanel(props) {
+ const set = props.editor.getParentBlock(props.block.id);
+ // `placeable: "namedOnly"` means a panel in the document is always in a
+ // tab set, so not finding one is a broken document. A panel that was
+ // just removed is not in the document, but it can render one last time
+ // before React unmounts it: the reader's open tab changes in the same
+ // step (`removeTab`). Then it shows closed.
+ if (!set && props.editor.getBlock(props.block.id)) {
+ throw new Error(`Tab panel "${props.block.id}" has no tab set`);
+ }
+ const open =
+ useActiveTabId(set?.id ?? "", set?.children ?? []) === props.block.id;
+
+ return (
+
+ );
+ },
+ },
+);
+
+/**
+ * One tab in the strip, draggable to reorder. The whole tab is the handle, and
+ * the sensor below only starts a drag after a few pixels of movement, so a
+ * plain click still opens the tab or its menu.
+ */
+function SortableTab(props: {
+ id: string;
+ active: boolean;
+ editing: boolean;
+ children: React.ReactNode;
+}) {
+ // Not draggable while its title is being edited, so selecting text in the
+ // field does not pick the tab up.
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: props.id, disabled: props.editing });
+
+ return (
+
+ {props.children}
+
+ );
+}
+
+/** The tab strip plus the panels. */
+export const createTabs = createReactBlockSpec(
+ {
+ type: "tabs",
+ propSchema: {},
+ content: "none",
+ layoutOnly: true,
+ // A tab is content, not layout: an empty tab stays, and the tab set
+ // stays when its last tab is removed (it gets a new, empty tab).
+ children: { allow: ["tab"], min: 1, dissolve: false },
+ },
+ {
+ renderFrame: function TabStrip(props) {
+ // The editor's own menu primitives, so the tab menu matches whichever
+ // UI library `BlockNoteView` is using (Mantine, Ariakit or ShadCN)
+ // instead of being styled by hand.
+ const Components = useComponentsContext()!;
+ // The menu opens in the editor's portal element, so it keeps the
+ // editor's theme and is not clipped by the tab set.
+ const portalElement = usePortalElement();
+ // A few pixels of movement before a drag starts, so clicking a tab
+ // still opens it rather than picking it up.
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
+ );
+ // The tab whose title is being edited in place, if any.
+ const [editingId, setEditingId] = useState(undefined);
+ const tabs = props.block.children;
+ useRevealCaretPanel(props.editor, props.block.id);
+ const active = useActiveTabId(props.block.id, tabs);
+ const activeIndex = tabs.findIndex((tab: any) => tab.id === active);
+
+ // Removing a panel does not re-render its siblings: a panel re-renders
+ // when its own block changes or when this store notifies, and a
+ // sibling disappearing is neither. Each survivor therefore keeps the
+ // `open` it last computed, which was `false` for all of them while the
+ // removed panel was the chosen one - so the set would show nothing at
+ // all. Storing the panel actually shown is what tells them.
+ //
+ // Nothing is stored until the reader has chosen, so this only ever
+ // repairs a choice, never makes one.
+ const ids = tabs.map((tab: any) => tab.id).join();
+ useEffect(() => {
+ const stored = openTabId(props.block.id);
+ if (stored !== undefined && stored !== active) {
+ openTab(props.block.id, active);
+ }
+ // `tabs` is a fresh array on every render; the ids are what matter.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [ids, active, props.block.id]);
+
+ const onDragEnd = (event: DragEndEvent) => {
+ const from = tabs.findIndex((tab: any) => tab.id === event.active.id);
+ const to = tabs.findIndex((tab: any) => tab.id === event.over?.id);
+ if (from !== -1 && to !== -1 && from !== to) {
+ move(from, to - from);
+ }
+ };
+
+ // The caret follows the switch when it was inside the tab set: leaving
+ // it in the panel that just closed would put the cursor somewhere the
+ // reader cannot see, and `useRevealCaretPanel` would immediately reopen
+ // that panel and undo the switch.
+ const open = (index: number) => {
+ const here = props.editor.getTextCursorPosition().block;
+ const caretInside =
+ props.editor.isFocused() &&
+ tabs.some((tab: any) => contains(tab, here.id));
+
+ // Opening a tab touches no document state, so it creates no undo
+ // step and no change for collaborators.
+ openTab(props.block.id, tabs[index].id);
+
+ if (caretInside) {
+ const landing = tabs[index].children[0];
+ if (!landing) {
+ throw new Error("A tab panel should hold a block for the caret");
+ }
+ props.editor.setTextCursorPosition(landing, "start");
+ }
+ };
+
+ const addTab = () => {
+ props.editor.insertBlocks(
+ [{ type: "tab", props: { label: `Tab ${tabs.length + 1}` } } as any],
+ tabs[tabs.length - 1],
+ "after",
+ );
+
+ // Land in the new panel so it can be typed into straight away.
+ const next = panelsOf(props.editor, props.block.id);
+ const added = next[next.length - 1];
+ const landing = added.children[0];
+ if (!landing) {
+ throw new Error("A new tab panel should hold a block to type in");
+ }
+ openTab(props.block.id, added.id);
+ props.editor.setTextCursorPosition(landing, "start");
+ props.editor.focus();
+ };
+
+ const removeTab = (index: number) => {
+ props.editor.removeBlocks([tabs[index]]);
+ // One tab always survives: the set does not dissolve
+ // (`dissolve: false`), so removing its last tab leaves a new, empty
+ // one.
+ const next = panelsOf(props.editor, props.block.id);
+ openTab(props.block.id, next[Math.min(index, next.length - 1)].id);
+ };
+
+ const move = (index: number, by: number) => {
+ const to = index + by;
+ if (to < 0 || to >= tabs.length) {
+ return;
+ }
+ // One transaction, so a move is a single undo step - and so the id
+ // is free again by the time it is re-inserted. Keeping it matters:
+ // the reader's open panel is remembered by id, and a new one would
+ // silently move their choice to another tab.
+ props.editor.transact(() => {
+ const moving = tabs[index];
+ props.editor.removeBlocks([moving]);
+ props.editor.insertBlocks(
+ [moving as any],
+ tabs[to],
+ by < 0 ? "before" : "after",
+ );
+ });
+ };
+
+ // Arrow keys move between tabs, as a tablist is expected to.
+ const onStripKeyDown = (event: React.KeyboardEvent) => {
+ const deltas: Record = { ArrowLeft: -1, ArrowRight: 1 };
+ const next =
+ event.key === "Home"
+ ? 0
+ : event.key === "End"
+ ? tabs.length - 1
+ : event.key in deltas
+ ? (activeIndex + deltas[event.key] + tabs.length) % tabs.length
+ : undefined;
+ if (next === undefined) {
+ return;
+ }
+ event.preventDefault();
+ open(next);
+ // Keep the strip focused so the next arrow keeps stepping.
+ (event.currentTarget as HTMLElement)
+ .querySelectorAll("[role='tab']")
+ [next]?.focus();
+ };
+
+ return (
+
+ {/* Chrome, not document content: it lives outside `slotRef`. */}
+
+ );
+ },
+ },
+);
diff --git a/examples/06-custom-schema/14-tabs-block/src/styles.css b/examples/06-custom-schema/14-tabs-block/src/styles.css
new file mode 100644
index 0000000000..a0fbd7d563
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/src/styles.css
@@ -0,0 +1,81 @@
+.tabs {
+ border: 1px solid #e4e4e7;
+ border-radius: 12px;
+ margin: 8px 0;
+ padding: 10px 14px 14px;
+}
+
+.tabs-strip {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ user-select: none;
+}
+
+.tabs-tab {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 999px;
+}
+
+.tabs-tab[data-active="true"] {
+ background: #f1f2f4;
+}
+
+/* Scoped to the tab's own label: the tab menu's dropdown renders inside the
+ tab, and a plain `button` selector would restyle its items too. */
+.tabs-label,
+.tabs-add {
+ border: none;
+ background: none;
+ cursor: pointer;
+ font: inherit;
+ color: #8a8f98;
+ padding: 5px 14px;
+ border-radius: 999px;
+}
+
+.tabs-tab[data-active="true"] .tabs-label {
+ color: #1f2024;
+ font-weight: 600;
+}
+
+.tabs-label:focus-visible,
+.tabs-add:focus-visible {
+ outline: 2px solid #3b82f6;
+ outline-offset: -2px;
+}
+
+/* Editing chrome stays out of the way until the reader means to use it. The
+ space is reserved either way, so revealing it does not shift the strip. */
+.tabs-add {
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.12s ease;
+}
+
+.tabs-strip:hover .tabs-add,
+.tabs-strip:focus-within .tabs-add {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.tabs-body {
+ /* An empty panel still needs to be clickable. */
+ min-height: 1.5rem;
+}
+
+/* Only the open panel is shown; the others stay in the document. The panel
+ sets this attribute itself from per-reader state, so it is always present
+ and `[data-active="false"]` would match too - `:not(...)` keeps it working
+ either way. */
+.tab-panel:not([data-active="true"]) {
+ display: none;
+}
+
+/* The title while it is being renamed: the same pill, now a text field. */
+input.tabs-label {
+ cursor: text;
+ outline: none;
+ width: 10ch;
+}
diff --git a/examples/06-custom-schema/14-tabs-block/tsconfig.json b/examples/06-custom-schema/14-tabs-block/tsconfig.json
new file mode 100644
index 0000000000..2aa62c56e6
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-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/14-tabs-block/vite-env.d.ts b/examples/06-custom-schema/14-tabs-block/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-block/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/06-custom-schema/14-tabs-block/vite.config.ts b/examples/06-custom-schema/14-tabs-block/vite.config.ts
new file mode 100644
index 0000000000..cbf6ff2ffc
--- /dev/null
+++ b/examples/06-custom-schema/14-tabs-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/15-stepper-block/.bnexample.json b/examples/06-custom-schema/15-stepper-block/.bnexample.json
new file mode 100644
index 0000000000..4751a892b8
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/.bnexample.json
@@ -0,0 +1,9 @@
+{
+ "playground": true,
+ "docs": true,
+ "author": "yousefed",
+ "tags": ["Intermediate", "Blocks", "Custom Schemas"],
+ "dependencies": {
+ "react-icons": "^5.5.0"
+ }
+}
diff --git a/examples/06-custom-schema/15-stepper-block/README.md b/examples/06-custom-schema/15-stepper-block/README.md
new file mode 100644
index 0000000000..73d87d3ba7
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/README.md
@@ -0,0 +1,61 @@
+# Stepper Block
+
+Numbered steps built on the layout block API. `stepper` is a layout block
+restricted to `step` children, and `step` is a `placeable: "namedOnly"`
+layout block, so a step can only exist inside a stepper.
+
+Each step holds any blocks: headings, lists, code. The numbers are a CSS
+counter, so they stay correct as steps are added, removed, reordered or
+undone. Nothing in the document has to be kept in sync.
+
+**Add step** below the last step adds an untitled one.
+
+Both blocks are layout blocks (`layoutOnly: true`), so they declare only
+`renderFrame`, and the child blocks go where `slotRef` is attached. A step can
+only be inside a stepper, so it gets no drag handle by default. The step sets
+`meta: { draggable: true }` to keep it, so the side menu can move or delete a
+step.
+
+## Things worth knowing
+
+**Enter starts the next step.** By default, Enter on an empty block at the end
+of a step moves that block out below the whole stepper, so the keyboard alone
+cannot start a new step. `enterStartsNextStep` makes Enter work the way it does
+in a list:
+
+1. Enter at the end of a step adds a block to that step.
+2. Enter again, on the empty block, starts the next step and puts the caret
+ in its title.
+3. Enter again, on the empty title, leaves the stepper. It leaves a paragraph
+ behind, not the empty heading the step was created with.
+
+The block's `experimental_keyboard` settings cannot insert a new step, so this
+is written as an extension, passed as the third argument of
+`createReactBlockSpec`.
+
+## Known limitations
+
+**The title is not enforced.** A step has no text of its own: its title is
+simply its first child, a heading. The user can turn that heading into any
+other block, delete it, or put blocks above it. A real title needs a block
+with its own text that may only appear inside a stepper, and the layout block API
+cannot express that yet: `placeable: "namedOnly"` requires a layout block (a
+block without content), and `children.allow` can only name layout blocks.
+
+**Empty steps stay.** The stepper sets `children.dissolve` to `false`, so a
+step that holds only an empty paragraph is not removed, and the stepper stays
+when its last step is removed.
+
+## What the layout block API gives you for free
+
+- Backspace at the start of a step's title first turns it into a paragraph.
+ A second Backspace moves it to the end of the previous step, or above the
+ stepper if it is in the first step.
+- A step stays when its blocks are removed. When the last step is removed,
+ the stepper stays and gets a new step with an empty paragraph.
+- Shift-Tab unnests inside a step and stops at the step's edge.
+
+**Relevant Docs:**
+
+- [Layout Blocks](/docs/features/custom-schemas/layout-blocks)
+- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)
diff --git a/examples/06-custom-schema/15-stepper-block/index.html b/examples/06-custom-schema/15-stepper-block/index.html
new file mode 100644
index 0000000000..f1f0d5da1b
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+ Stepper Block
+
+
+
+
+
+
+
diff --git a/examples/06-custom-schema/15-stepper-block/main.tsx b/examples/06-custom-schema/15-stepper-block/main.tsx
new file mode 100644
index 0000000000..1260513388
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-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/15-stepper-block/package.json b/examples/06-custom-schema/15-stepper-block/package.json
new file mode 100644
index 0000000000..5e67e12486
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@blocknote/example-custom-schema-stepper-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/15-stepper-block/src/App.tsx b/examples/06-custom-schema/15-stepper-block/src/App.tsx
new file mode 100644
index 0000000000..bbbec25373
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/src/App.tsx
@@ -0,0 +1,108 @@
+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 { RiListOrdered2 } from "react-icons/ri";
+
+import { createStep, createStepper } from "./Stepper";
+import "./styles.css";
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ stepper: createStepper(),
+ step: createStep(),
+ },
+});
+
+function insertStepper(editor: typeof schema.BlockNoteEditor) {
+ return {
+ title: "Stepper",
+ subtext: "Numbered steps, each holding any blocks",
+ onItemClick: () =>
+ insertOrUpdateBlockForSlashMenu(editor, {
+ type: "stepper",
+ children: [
+ {
+ type: "step",
+ children: [
+ { type: "heading", props: { level: 3 }, content: "First step" },
+ ],
+ },
+ {
+ type: "step",
+ children: [
+ { type: "heading", props: { level: 3 }, content: "Second step" },
+ ],
+ },
+ ],
+ } as any),
+ aliases: ["stepper", "steps", "guide", "walkthrough"],
+ group: "Layout",
+ icon: ,
+ };
+}
+
+export default function App() {
+ const editor = useCreateBlockNote({
+ schema,
+ initialContent: [
+ {
+ type: "paragraph",
+ content: "A stepper built on the layout block API. Numbering is CSS.",
+ },
+ {
+ type: "stepper",
+ children: [
+ {
+ type: "step",
+ children: [
+ { type: "heading", props: { level: 3 }, content: "Install" },
+ { type: "paragraph", content: "Download and run the installer." },
+ ],
+ },
+ {
+ type: "step",
+ children: [
+ { type: "heading", props: { level: 3 }, content: "Configure" },
+ { type: "paragraph", content: "Any block works inside a step:" },
+ { type: "bulletListItem", content: "lists" },
+ { type: "codeBlock", content: "code()" },
+ ],
+ },
+ {
+ type: "step",
+ children: [
+ { type: "heading", props: { level: 3 }, content: "Ship" },
+ { type: "paragraph", content: "Deploy it." },
+ ],
+ },
+ ],
+ } as any,
+ { type: "paragraph", content: "Press '/' to insert another stepper." },
+ { type: "paragraph" },
+ ],
+ });
+
+ return (
+
+
+ filterSuggestionItems(
+ [...getDefaultReactSlashMenuItems(editor), insertStepper(editor)],
+ query,
+ )
+ }
+ />
+
+ );
+}
diff --git a/examples/06-custom-schema/15-stepper-block/src/Stepper.tsx b/examples/06-custom-schema/15-stepper-block/src/Stepper.tsx
new file mode 100644
index 0000000000..6888342f4d
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/src/Stepper.tsx
@@ -0,0 +1,169 @@
+import type { BlockNoteEditor } from "@blocknote/core";
+import { createExtension } from "@blocknote/core";
+import { createReactBlockSpec } from "@blocknote/react";
+
+import "./styles.css";
+
+/**
+ * A new, untitled step.
+ *
+ * Its title is an empty heading, the right block for a title.
+ */
+function newStep() {
+ return {
+ type: "step",
+ children: [{ type: "heading", props: { level: 3 } }],
+ };
+}
+
+/**
+ * Enter on an empty block at the end of a step starts the next step, the way
+ * Enter on an empty list item starts the next item. BlockNote's own Enter
+ * handling would move that block out below the whole stepper, which leaves no
+ * keyboard route to a new step.
+ *
+ * The block's `experimental_keyboard` settings cannot insert a new step, so
+ * this is the one piece that needs an extension, attached through
+ * `createReactBlockSpec`'s third argument.
+ */
+const enterStartsNextStep = createExtension({
+ key: "stepper-enter-starts-next-step",
+ keyboardShortcuts: {
+ Enter: ({ editor }) => {
+ const { block } = editor.getTextCursorPosition();
+ const isEmpty =
+ Array.isArray(block.content) &&
+ block.content.length === 0 &&
+ block.children.length === 0;
+ if (!isEmpty) {
+ return false;
+ }
+ const step = editor.getParentBlock(block);
+ if (step?.type !== "step") {
+ return false;
+ }
+
+ // The step holds nothing but this empty block - typically the title of
+ // a step the previous Enter just started - so this Enter leaves the
+ // stepper. The built-in handling would carry the empty heading out;
+ // replacing the step with a paragraph below the stepper leaves the
+ // caret in plain text instead.
+ if (step.children.length === 1) {
+ const stepper = editor.getParentBlock(step);
+ if (!stepper) {
+ throw new Error(`Step "${step.id}" is not inside a stepper`);
+ }
+ const [landing] = editor.transact(() => {
+ const inserted = editor.insertBlocks(
+ [{ type: "paragraph" }],
+ stepper,
+ "after",
+ );
+ // If this was the stepper's only step, the stepper goes too. It
+ // does not dissolve by itself (`dissolve: false`): without this,
+ // it would stay with a new, empty step.
+ editor.removeBlocks([stepper.children.length === 1 ? stepper : step]);
+ return inserted;
+ });
+ editor.setTextCursorPosition(landing, "start");
+ return true;
+ }
+
+ // Otherwise only the *last* block of a step opens the next one.
+ if (step.children[step.children.length - 1].id !== block.id) {
+ return false;
+ }
+ const [next] = editor.transact(() => {
+ editor.removeBlocks([block]);
+ return editor.insertBlocks([newStep()], step, "after");
+ });
+ editor.setTextCursorPosition(next.children[0], "start");
+ return true;
+ },
+ },
+});
+
+/** Appends a step and puts the caret in its title. */
+function addStep(editor: BlockNoteEditor, stepperId: string) {
+ // Read back rather than using the render's snapshot of the stepper.
+ const stepper = editor.getBlock(stepperId);
+ if (!stepper) {
+ throw new Error(`Stepper "${stepperId}" is not in the document`);
+ }
+ const [step] = editor.insertBlocks(
+ [newStep()],
+ stepper.children[stepper.children.length - 1],
+ "after",
+ );
+ // Land in the new step's title so it can be named straight away.
+ editor.setTextCursorPosition(step.children[0], "start");
+ editor.focus();
+}
+
+/**
+ * One step. Like a column, a step is defined only in terms of the thing that
+ * holds it, so it declares `placeable: "namedOnly"`: the schema keeps it out
+ * of every other position, and moving one out dissolves it into its blocks.
+ *
+ * A step has no text of its own - its title is simply its first child. The
+ * layout block API cannot express "a block with its own rich text that may only
+ * appear inside a stepper": `children.allow` names layout block types only, and
+ * a block with content is not one.
+ */
+export const createStep = createReactBlockSpec(
+ {
+ type: "step",
+ propSchema: {},
+ content: "none",
+ layoutOnly: true,
+ placeable: "namedOnly",
+ },
+ {
+ // A step that can only be inside a stepper gets no drag handle by
+ // default. This one keeps it, so the side menu can move or delete it.
+ meta: { draggable: true },
+ renderFrame: (props) => (
+
+ {/* Chrome, not content: it sits outside `slotRef`. The number
+ comes from a CSS counter. */}
+
+
+
+
+
+ ),
+ },
+);
+
+export const createStepper = createReactBlockSpec(
+ {
+ type: "stepper",
+ propSchema: {},
+ content: "none",
+ layoutOnly: true,
+ // A step is content, not layout: an empty step stays, and the stepper
+ // stays when its last step is removed (it gets a new, empty step).
+ children: { allow: ["step"], min: 1, dissolve: false },
+ },
+ {
+ renderFrame: (props) => {
+ return (
+
+
+
+
+ );
+ },
+ },
+ [enterStartsNextStep],
+);
diff --git a/examples/06-custom-schema/15-stepper-block/src/styles.css b/examples/06-custom-schema/15-stepper-block/src/styles.css
new file mode 100644
index 0000000000..6e033a9197
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/src/styles.css
@@ -0,0 +1,102 @@
+.stepper {
+ /* The rail runs behind the step numbers, from the first one down to the
+ "+" of "Add step". It is drawn once here rather than per step, so it
+ needs no "is this the last step" logic. */
+ position: relative;
+ margin: 8px 0;
+}
+
+.stepper::before {
+ content: "";
+ position: absolute;
+ left: 12px;
+ /* The centre of the first number. */
+ top: 33px;
+ bottom: 12px;
+ width: 1px;
+ background: var(--bn-colors-border);
+}
+
+/* Numbering is a CSS counter, so it stays correct as steps are added,
+ removed, dragged or undone - nothing to keep in sync in the document. */
+.stepper-steps {
+ counter-reset: step;
+}
+
+.step {
+ display: flex;
+ /* The side menu of a block in a step is at the left of that block. This gap
+ is wider than the side menu (48px), so the menu does not cover the
+ number. */
+ gap: 12px;
+ padding-bottom: 8px;
+}
+
+.step-rail {
+ flex: none;
+ width: 25px;
+ /* Centres the number on the first line of a step's title. The title is a
+ heading, and its first line starts 21px below its top: 18px of heading
+ spacing and 3px of line spacing. */
+ padding-top: 21px;
+}
+
+/* Every circle gets the editor's background so the rail passes behind it. */
+.step-number,
+.stepper-add-icon {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 25px;
+ height: 25px;
+ box-sizing: border-box;
+ border: 1px solid var(--bn-colors-border);
+ border-radius: 999px;
+ background: var(--bn-colors-editor-background);
+ color: var(--bn-colors-side-menu);
+ font: inherit;
+ font-size: 0.8rem;
+ padding: 0;
+ user-select: none;
+}
+
+.step-number {
+ counter-increment: step;
+ color: var(--bn-colors-editor-text);
+}
+
+.step-number::before {
+ content: counter(step);
+}
+
+.stepper-add:focus-visible {
+ outline: 2px solid #3b82f6;
+ outline-offset: 1px;
+}
+
+.step-body {
+ flex: 1;
+ min-width: 0;
+}
+
+/* Do not remove the top spacing of the step's title (a heading). The side
+ menu is centred on the first line of a heading with the default spacing
+ only. With less spacing, the menu is below the line. */
+
+.stepper-add {
+ display: flex;
+ align-items: center;
+ /* The same as the gap of a step, so "Add step" aligns with the steps. */
+ gap: 12px;
+ border: none;
+ background: none;
+ padding: 0;
+ cursor: pointer;
+ font: inherit;
+ color: var(--bn-colors-side-menu);
+}
+
+.stepper-add:hover {
+ color: var(--bn-colors-hovered-text);
+}
diff --git a/examples/06-custom-schema/15-stepper-block/tsconfig.json b/examples/06-custom-schema/15-stepper-block/tsconfig.json
new file mode 100644
index 0000000000..2aa62c56e6
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-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/15-stepper-block/vite-env.d.ts b/examples/06-custom-schema/15-stepper-block/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-block/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/06-custom-schema/15-stepper-block/vite.config.ts b/examples/06-custom-schema/15-stepper-block/vite.config.ts
new file mode 100644
index 0000000000..cbf6ff2ffc
--- /dev/null
+++ b/examples/06-custom-schema/15-stepper-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..8d5cab072f 100644
--- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts
@@ -8,12 +8,48 @@ import {
it,
} from "vite-plus/test";
+import { BlockNoteSchema } from "../../../../blocks/BlockNoteSchema.js";
+import { defaultBlockSpecs } from "../../../../blocks/defaultBlocks.js";
import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
+import { createBlockSpec } from "../../../../schema/blocks/createSpec.js";
+
+// The editor stays headless, so these blocks are never rendered.
+// `renderFrame` only has to exist for `createBlockSpec` to accept the spec.
+const layoutBlock = (type: string, config: Record) =>
+ createBlockSpec({ type, propSchema: {}, ...config } as any, {
+ renderFrame: () => {
+ throw new Error("not rendered in this suite");
+ },
+ })();
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ // A layout block of any blocks. `"first-child"`/`"last-child"` insert at
+ // the start or end of its children.
+ box: layoutBlock("box", {
+ content: "none",
+ layoutOnly: true,
+ }),
+ // A layout block that only accepts other layout blocks, so an insertion has to
+ // descend a level to find a place for a regular block.
+ grid: layoutBlock("grid", {
+ content: "none",
+ layoutOnly: true,
+ children: { allow: ["cell"], min: 2 },
+ }),
+ cell: layoutBlock("cell", {
+ content: "none",
+ layoutOnly: true,
+ placeable: "namedOnly",
+ }),
+ } as const,
+});
let editor: BlockNoteEditor;
beforeAll(() => {
- editor = BlockNoteEditor.create() as any;
+ editor = BlockNoteEditor.create({ schema }) as any;
});
afterAll(() => {
@@ -28,86 +64,154 @@ 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("prepends and appends around existing children", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "b-0",
+ type: "box",
+ children: [{ id: "existing", type: "paragraph", content: "Existing" }],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
editor.insertBlocks(
[{ id: "first", type: "paragraph" }],
- "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("prepends and appends around existing children", () => {
+ it("descends into a nested layout block that accepts the block", () => {
editor.replaceBlocks(editor.document, [
{
- id: "p-0",
- type: "paragraph",
- content: "Paragraph 0",
- children: [{ id: "existing", type: "paragraph", content: "Existing" }],
+ id: "g-0",
+ type: "grid",
+ children: [
+ { id: "c-0", type: "cell" },
+ { id: "c-1", type: "cell" },
+ ],
},
+ { id: "trailing", type: "paragraph", content: "" },
]);
+ // `grid` itself only accepts `cell`s, so both placements have to find the
+ // leading/trailing cell rather than giving up.
editor.insertBlocks(
[{ id: "first", type: "paragraph" }],
- "p-0",
+ "g-0",
"first-child",
);
editor.insertBlocks(
[{ id: "last", type: "paragraph" }],
+ "g-0",
+ "last-child",
+ );
+
+ const grid = editor.getBlock("g-0")!;
+ expect(grid.children[0].children.map((child: any) => child.id)).toContain(
+ "first",
+ );
+ expect(grid.children[1].children.map((child: any) => child.id)).toContain(
+ "last",
+ );
+ });
+
+ it("nests under a regular block, with or without existing children", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Paragraph 0" },
+ ]);
+
+ editor.insertBlocks(
+ [{ id: "existing", type: "paragraph" }],
"p-0",
"last-child",
);
+ editor.insertBlocks(
+ [{ id: "first", type: "paragraph" }],
+ "p-0",
+ "first-child",
+ );
expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([
"first",
"existing",
- "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("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/);
+ });
- expect(editor.document.map((block) => block.id)).toEqual([
- "after",
- "before",
- "p-0",
- "sibling",
+ 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: "existing", type: "paragraph" }],
+ },
+ { 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([
+ "existing",
"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..1aaf1f388a 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 { layoutSchema } from "../../layoutBlocks/layoutBlocks.fixture.js";
import { mergeBlocksCommand } from "./mergeBlocks.js";
const getEditor = setupTestEnv();
@@ -41,6 +43,23 @@ describe("Test mergeBlocks", () => {
mergeBlocks(getPosBeforeSelectedBlock());
+ // The merged block's children are lifted into its place, not dropped
+ // with it. The lift and the delete go through the one transaction tiptap
+ // hands the command (its chainable state's `tr` getter returns the same
+ // transaction every time), so the lift must survive the dispatch.
+ expect(getEditor().getBlock("paragraph-with-children")).toBeUndefined();
+ expect(getEditor().getBlock("paragraph-1")!.content).toEqual([
+ { type: "text", text: "Paragraph 1Paragraph with children", styles: {} },
+ ]);
+ expect(getEditor().document.map((block) => block.id)).toContain(
+ "nested-paragraph-0",
+ );
+ expect(
+ getEditor()
+ .getBlock("nested-paragraph-0")!
+ .children.map((c) => c.id),
+ ).toEqual(["double-nested-paragraph-0"]);
+
expect(getEditor().document).toMatchSnapshot();
});
@@ -98,19 +117,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 +165,87 @@ describe("Test mergeBlocks", () => {
expect(ret).toBeFalsy();
});
});
+
+describe("Test mergeBlocks at layout block boundaries", () => {
+ const getLayoutEditor = setupTestEnv({
+ schema: layoutSchema,
+ 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 mergeLayoutBlocks(posBetweenBlocks: number) {
+ return getLayoutEditor()._tiptapEditor.commands.command(
+ mergeBlocksCommand(posBetweenBlocks),
+ );
+ }
+
+ function getPosBefore(id: string) {
+ return getLayoutEditor().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 layout block's first child has no previous sibling, so there is nothing to
+ // merge it into. The block above it on screen sits outside the layout block.
+ it("Does not merge a layout block's first child out of the layout block", () => {
+ const originalDocument = getLayoutEditor().document;
+ const ret = mergeLayoutBlocks(getPosBefore("callout-child-0"));
+
+ expect(ret).toBeFalsy();
+ expect(getLayoutEditor().document).toEqual(originalDocument);
+ });
+
+ // A layout block has no content of its own, so there is nothing to merge.
+ it("Does not merge a layout block into the block above it", () => {
+ const originalDocument = getLayoutEditor().document;
+ const ret = mergeLayoutBlocks(getPosBefore("callout-0"));
+
+ expect(ret).toBeFalsy();
+ expect(getLayoutEditor().document).toEqual(originalDocument);
+ });
+
+ // `mergeBlocksCommand` treats a layout block like any other block with children
+ // and merges into its last descendant, which puts the merged text inside the
+ // layout block. Backspace never produces this, because
+ // `KeyboardShortcutsExtension` bails out when the previous sibling has no
+ // inline content and moves the block into the layout block 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 layout block above it", () => {
+ const ret = mergeLayoutBlocks(getPosBefore("after-callout"));
+
+ expect(ret).toBeTruthy();
+
+ const document = getLayoutEditor().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..df51fdef67 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,43 @@ 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. `state.tr` here is not a
+ // fresh transaction: tiptap's chainable state (`createChainableState`)
+ // returns the one transaction the command runs in from every `tr` read,
+ // so this lift and the delete below are dispatched together. The "Second
+ // block has children" test in mergeBlocks.test.ts pins that the lifted
+ // children survive the merge.
if (dispatch && nextBlockInfo.children) {
const childBlocksRange = state.doc
.resolve(nextBlockInfo.children.childrenStart)
@@ -156,10 +179,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..4582f07b1c 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 { layoutSchema } from "../../layoutBlocks/layoutBlocks.fixture.js";
+import { setupTestEnv, testDocument } from "../../setupTestEnv.js";
import {
moveBlocksDown,
moveBlocksUp,
moveSelectedBlocksAndSelection,
} from "./moveBlocks.js";
-const getEditor = setupTestEnv();
+const getEditor = setupTestEnv<
+ typeof layoutSchema.blockSchema,
+ typeof layoutSchema.inlineContentSchema,
+ typeof layoutSchema.styleSchema
+>({
+ schema: layoutSchema,
+ 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..09a05dec14 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,
+ isLayoutNode,
+ 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
+ * layout block that holds it, so it can't land anywhere a regular block goes —
+ * moving one out of its layout block moves its children instead. Every other
+ * block passes through as itself.
+ */
+function dissolveLayoutOnlyBlocks(
+ blocks: Block[],
+ pmSchema: Schema,
+): Block[] {
+ return blocks.flatMap((block) =>
+ isNamedOnly(pmSchema.nodes[block.type])
+ ? dissolveLayoutOnlyBlocks(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, [], { fixLayoutBlocks: false });
insertBlocks(
tr,
- flattenColumns(blocks),
+ dissolveLayoutOnlyBlocks(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 layout-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 = dissolveLayoutOnlyBlocks([block], editor.pmSchema)[0];
+ const type = first && editor.pmSchema.nodes[first.type];
+ return type && isLayoutNode(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..fb83bc8a10 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 { layoutSchema } from "../../layoutBlocks/layoutBlocks.fixture.js";
/**
* Custom test setup with a document designed to reproduce nesting/unnesting bugs.
@@ -646,6 +647,261 @@ describe("unnestBlock / liftListItem", () => {
});
});
+// A second editor, on a schema that has layout blocks. `setupNestTestEnv`
+// builds a default-schema editor, which can't express any of the cases below.
+function setupLayoutNestTestEnv() {
+ let editor: BlockNoteEditor;
+ const div = document.createElement("div");
+
+ beforeAll(() => {
+ editor = BlockNoteEditor.create({ schema: layoutSchema });
+ 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 layout blocks.
+describe("canNestBlock / canUnnestBlock around layout blocks", () => {
+ const withLayoutEditor = setupLayoutNestTestEnv();
+
+ it("Reports that a block cannot be nested under a layout block sibling", () => {
+ const editor = withLayoutEditor([
+ {
+ 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 layout block's child cannot be unnested out of it", () => {
+ const editor = withLayoutEditor([
+ {
+ 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 = withLayoutEditor([
+ { 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 layout block's children", () => {
+ const editor = withLayoutEditor([
+ {
+ 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);
+ });
+
+ // The nest step moves the block into the previous sibling's children, where
+ // ProseMirror can refuse it. That refusal has to come back as `false`
+ // rather than escape as a `TransformError`: `canNestBlock` runs on every
+ // selection change (the formatting toolbar's nest buttons), and Tab runs
+ // `nestBlock` in the keymap.
+ //
+ // Here the previous sibling's children end in a layout block. The step opens
+ // into that last child to join the block onto it, which only works for a
+ // `blockContainer`: "Cannot join blockContainer onto callout".
+ it("Nests after a layout block at the end of the previous sibling's children", () => {
+ const editor = withLayoutEditor([
+ {
+ id: "paragraph-0",
+ type: "paragraph",
+ content: "Paragraph 0",
+ children: [
+ {
+ id: "callout-0",
+ type: "callout",
+ children: [
+ { id: "callout-child", type: "paragraph", content: "In callout" },
+ ],
+ },
+ ],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+
+ editor.setTextCursorPosition("after", "start");
+
+ expect(editor.canNestBlock()).toBe(true);
+ editor.nestBlock();
+ expect(
+ editor.getBlock("paragraph-0")!.children.map((child) => child.id),
+ ).toEqual(["callout-0", "after"]);
+ expect(editor.getTextCursorPosition().block.id).toBe("after");
+ });
+});
+
+// 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 layout block's children", () => {
+ const withLayoutEditor = setupLayoutNestTestEnv();
+
+ 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 = withLayoutEditor([
+ { 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 = withLayoutEditor([
+ {
+ 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 = withLayoutEditor([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..52136f092e 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
);
}
@@ -32,6 +35,10 @@ function holdsItems(node: Node, itemType: NodeType) {
* 2. nestedBefore checks groupType instead of parent.type
* 3. Slice creates groupType instead of parent.type
* 4. Operates on Transaction directly instead of state+dispatch
+ * 5. Opens the slice into the previous sibling's child group, not into the
+ * group's last child. The original cut one level deeper to keep `insert`
+ * at 1; that failed when the last child was a layout block node (a column
+ * list), which a `blockContainer` can't be joined onto.
*/
function sinkItem(tr: Transaction, itemType: NodeType, groupType: NodeType) {
const { $from, $to } = tr.selection;
@@ -50,26 +57,32 @@ function sinkItem(tr: Transaction, itemType: NodeType, groupType: NodeType) {
}
const nestedBefore =
nodeBefore.lastChild && nodeBefore.lastChild.type === groupType; // change 2
- const inner = Fragment.from(nestedBefore ? itemType.create() : null);
+ // The slice opens into the previous sibling and, when it already has a child
+ // group, into that group, so the block becomes its last child. (change 5)
+ const depth = nestedBefore ? 2 : 1;
const slice = new Slice(
- Fragment.from(
- itemType.create(null, Fragment.from(groupType.create(null, inner))), // change 3
- ),
- nestedBefore ? 3 : 1,
+ Fragment.from(itemType.create(null, Fragment.from(groupType.create()))), // change 3
+ depth,
0,
);
const before = range.start;
const after = range.end;
+ // Nothing above can make the schema refuse this step: `parent` holds
+ // regular blocks, so it may lose one (a layout block of any blocks has no
+ // `min` above 1), and the slice only joins the previous sibling and its
+ // group. A refusal here would be a bug, so it throws.
tr.step(
new ReplaceAroundStep(
- before - (nestedBefore ? 3 : 1),
+ before - depth,
after,
before,
after,
slice,
- 1,
+ // `Slice.insertAt` adds the slice's open depth, so this lands at content
+ // position 2: inside the group.
+ 2 - depth,
true,
),
).scrollIntoView();
@@ -175,6 +188,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 +198,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 +221,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 +237,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 layout 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..d2d43aa101 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,
+ getAncestorLayoutBlocks,
+} 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 { fixLayoutBlocksById } from "../../layoutBlocks/fixLayoutBlock.js";
export function removeAndInsertBlocks<
BSchema extends BlockSchema,
@@ -22,7 +25,7 @@ export function removeAndInsertBlocks<
blocksToRemove: BlockIdentifier[],
blocksToInsert: PartialBlock[],
options: {
- fixColumns?: boolean;
+ fixLayoutBlocks?: boolean;
} = {},
): {
insertedBlocks: Block[];
@@ -43,13 +46,21 @@ export function removeAndInsertBlocks<
),
);
const removedBlocks: Block[] = [];
- const columnListPositions = new Set();
+ // Ancestor layout blocks 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 layoutBlocksToFix: { 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 layoutBlock of getAncestorLayoutBlocks($pos.doc, $pos.pos)) {
+ if (!layoutBlocksToFix.some((c) => c.id === layoutBlock.id)) {
+ layoutBlocksToFix.push(layoutBlock);
+ }
}
+ // When the block is the only child of a nested `blockGroup`, delete the
+ // group with it (`blockGroup` acting as a `min: 1` layout block that unwraps
+ // when emptied). This can't route through `fixLayoutBlock`: 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 layout blocks 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 layout blocks as-is.
+ if (options.fixLayoutBlocks !== false) {
+ fixLayoutBlocksById(tr, layoutBlocksToFix);
}
// Converts the nodes created from `blocksToInsert` into full `Block`s.
diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts
deleted file mode 100644
index 3097851f47..0000000000
--- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts
+++ /dev/null
@@ -1,173 +0,0 @@
-import { Slice, type Node } from "prosemirror-model";
-import { type Transaction } from "prosemirror-state";
-import { ReplaceAroundStep } from "prosemirror-transform";
-
-/**
- * Checks if a `column` node is empty, i.e. if it has only a single empty
- * paragraph.
- * @param column The column to check.
- * @returns Whether the column is empty.
- */
-export function isEmptyColumn(column: Node) {
- if (!column || column.type.name !== "column") {
- throw new Error("Invalid columnPos: does not point to column node.");
- }
-
- const blockContainer = column.firstChild;
- if (!blockContainer) {
- throw new Error("Invalid column: does not have child node.");
- }
-
- const blockContent = blockContainer.firstChild;
- if (!blockContent) {
- throw new Error("Invalid blockContainer: does not have child node.");
- }
-
- return (
- column.childCount === 1 &&
- blockContainer.childCount === 1 &&
- blockContent.type.name === "paragraph" &&
- blockContent.content.content.length === 0
- );
-}
-
-/**
- * Removes all empty `column` nodes in a `columnList`. A `column` node is empty
- * if it has only a single empty block. If, however, removing the `column`s
- * leaves the `columnList` that has fewer than two, ProseMirror will re-add
- * empty columns.
- * @param tr The `Transaction` to add the changes to.
- * @param columnListPos The position just before the `columnList` node.
- */
-export function removeEmptyColumns(tr: Transaction, columnListPos: number) {
- const $columnListPos = tr.doc.resolve(columnListPos);
- const columnList = $columnListPos.nodeAfter;
- if (!columnList || columnList.type.name !== "columnList") {
- throw new Error(
- "Invalid columnListPos: does not point to columnList node.",
- );
- }
-
- for (
- let columnIndex = columnList.childCount - 1;
- columnIndex >= 0;
- columnIndex--
- ) {
- const columnPos = tr.doc
- .resolve($columnListPos.pos + 1)
- .posAtIndex(columnIndex);
- const $columnPos = tr.doc.resolve(columnPos);
- const column = $columnPos.nodeAfter;
- if (!column || column.type.name !== "column") {
- throw new Error("Invalid columnPos: does not point to column node.");
- }
-
- if (isEmptyColumn(column)) {
- tr.delete(columnPos, columnPos + column.nodeSize);
- }
- }
-}
-
-/**
- * Fixes potential issues in a `columnList` node after a
- * `blockContainer`/`column` node is (re)moved from it:
- *
- * - Removes all empty `column` nodes. A `column` node is empty if it has only
- * a single empty block.
- * - If all but one `column` nodes are empty, replaces the `columnList` with
- * the content of the non-empty `column`.
- * - If all `column` nodes are empty, removes the `columnList` entirely.
- * @param tr The `Transaction` to add the changes to.
- * @param columnListPos
- * @returns The position just before the `columnList` node.
- */
-export function fixColumnList(tr: Transaction, columnListPos: number) {
- removeEmptyColumns(tr, columnListPos);
-
- const $columnListPos = tr.doc.resolve(columnListPos);
- const columnList = $columnListPos.nodeAfter;
- if (!columnList || columnList.type.name !== "columnList") {
- throw new Error(
- "Invalid columnListPos: does not point to columnList node.",
- );
- }
-
- if (columnList.childCount > 2) {
- // Do nothing if the `columnList` has more than two non-empty `column`s. In
- // the case that the `columnList` has exactly two columns, we may need to
- // still remove it, as it's possible that one or both columns are empty.
- // This is because after `removeEmptyColumns` is called, if the
- // `columnList` has fewer than two `column`s, ProseMirror will re-add empty
- // `column`s until there are two total, in order to fit the schema.
- return;
- }
-
- if (columnList.childCount < 2) {
- // Throw an error if the `columnList` has fewer than two columns. After
- // `removeEmptyColumns` is called, if the `columnList` has fewer than two
- // `column`s, ProseMirror will re-add empty `column`s until there are two
- // total, in order to fit the schema. So if there are fewer than two here,
- // either the schema, or ProseMirror's internals, must have changed.
- throw new Error("Invalid columnList: contains fewer than two children.");
- }
-
- const firstColumnBeforePos = columnListPos + 1;
- const $firstColumnBeforePos = tr.doc.resolve(firstColumnBeforePos);
- const firstColumn = $firstColumnBeforePos.nodeAfter;
-
- const lastColumnAfterPos = columnListPos + columnList.nodeSize - 1;
- const $lastColumnAfterPos = tr.doc.resolve(lastColumnAfterPos);
- const lastColumn = $lastColumnAfterPos.nodeBefore;
-
- if (!firstColumn || !lastColumn) {
- throw new Error("Invalid columnList: does not contain children.");
- }
-
- const firstColumnEmpty = isEmptyColumn(firstColumn);
- const lastColumnEmpty = isEmptyColumn(lastColumn);
-
- if (firstColumnEmpty && lastColumnEmpty) {
- // Removes `columnList`
- tr.delete(columnListPos, columnListPos + columnList.nodeSize);
-
- return;
- }
-
- if (firstColumnEmpty) {
- tr.step(
- new ReplaceAroundStep(
- // Replaces `columnList`.
- columnListPos,
- columnListPos + columnList.nodeSize,
- // Replaces with content of last `column`.
- lastColumnAfterPos - lastColumn.nodeSize + 1,
- lastColumnAfterPos - 1,
- // Doesn't append anything.
- Slice.empty,
- 0,
- false,
- ),
- );
-
- return;
- }
-
- if (lastColumnEmpty) {
- tr.step(
- new ReplaceAroundStep(
- // Replaces `columnList`.
- columnListPos,
- columnListPos + columnList.nodeSize,
- // Replaces with content of first `column`.
- firstColumnBeforePos + 1,
- firstColumnBeforePos + firstColumn.nodeSize - 1,
- // Doesn't append anything.
- Slice.empty,
- 0,
- false,
- ),
- );
-
- return;
- }
-}
diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts
index b403aec535..9f2ba196f5 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 { layoutSchema } from "../../layoutBlocks/layoutBlocks.fixture.js";
import { setupTestEnv } from "../../setupTestEnv.js";
import { splitBlockCommand } from "./splitBlock.js";
@@ -17,9 +18,10 @@ function splitBlock(
posInBlock: number,
keepType?: boolean,
keepProps?: boolean,
+ keepChildren?: boolean,
) {
getEditor()._tiptapEditor.commands.command(
- splitBlockCommand(posInBlock, keepType, keepProps),
+ splitBlockCommand(posInBlock, keepType, keepProps, keepChildren),
);
}
@@ -147,3 +149,216 @@ 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 layout block's children holder is a
+// different node type than `blockGroup`, so these pin that the split lands
+// inside the layout block rather than tearing it open.
+describe("Test splitBlocks inside layout blocks", () => {
+ const getLayoutEditor = setupTestEnv({
+ schema: layoutSchema,
+ 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 splitLayoutBlock(
+ blockId: string,
+ offset: number,
+ keepChildren = false,
+ ) {
+ const editor = getLayoutEditor();
+
+ 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 layout block 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, undefined, keepChildren),
+ );
+ }
+
+ function textOf(block: { content?: any }) {
+ return (block.content as { text: string }[]).map((c) => c.text).join("");
+ }
+
+ it("Splits a block inside a layout block in place", () => {
+ expect(splitLayoutBlock("callout-child-0", 7)).toBe(true);
+
+ const document = getLayoutEditor().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(() => getLayoutEditor().prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("Moves the block's children to the second half of the split", () => {
+ expect(splitLayoutBlock("callout-child-1", 7)).toBe(true);
+
+ const callout = getLayoutEditor().document[1];
+
+ expect(callout.children.map(textOf)).toEqual([
+ "Callout child",
+ "Callout",
+ " heading",
+ ]);
+ // The children follow the second half, as they do at the top level.
+ expect(callout.children[1].children).toEqual([]);
+ expect(callout.children[2].children.map((child) => child.id)).toEqual([
+ "nested-child",
+ ]);
+
+ expect(() => getLayoutEditor().prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("Keeps the block's children on the first half with `keepChildren`", () => {
+ expect(splitLayoutBlock("callout-child-1", 7, true)).toBe(true);
+
+ const callout = getLayoutEditor().document[1];
+
+ expect(callout.children.map(textOf)).toEqual([
+ "Callout child",
+ "Callout",
+ " heading",
+ ]);
+ expect(callout.children[2].children).toEqual([]);
+ expect(callout.children[1].children.map((child) => child.id)).toEqual([
+ "nested-child",
+ ]);
+
+ expect(() => getLayoutEditor().prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("Splits a block inside a nested layout block", () => {
+ expect(splitLayoutBlock("cell-0-p", 4)).toBe(true);
+
+ const grid = getLayoutEditor().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(() => getLayoutEditor().prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("Does not split a layout block itself", () => {
+ const before = getLayoutEditor().document;
+
+ expect(splitLayoutBlock("callout-0", 0)).toBe(false);
+
+ expect(getLayoutEditor().document).toEqual(before);
+ });
+});
+
+// By default the children follow the text after the caret into the second
+// block. `keepChildren` (a closed toggle) keeps them with the first half,
+// except at the start, where the whole content moves down.
+describe("split child ownership", () => {
+ it.each([
+ ["start", false, "second"],
+ ["middle", false, "second"],
+ ["end", false, "second"],
+ ["start", true, "second"],
+ ["middle", true, "first"],
+ ["end", true, "first"],
+ ] as const)(
+ "splitting at the %s with keepChildren %s leaves the children with the %s block",
+ (where, keepChildren, owner) => {
+ 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,
+ undefined,
+ undefined,
+ keepChildren,
+ );
+ const index = editor.document.findIndex((block) => block.id === id);
+ const [first, second] = editor.document.slice(index, index + 2);
+ expect((owner === "first" ? first : second).children).toEqual(children);
+ expect((owner === "first" ? second : first).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..96b82dbd22 100644
--- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
@@ -10,6 +10,7 @@ export const splitBlockCommand = (
posInBlock: number,
keepType?: boolean,
keepProps?: boolean,
+ keepChildren?: boolean,
) => {
return ({
state,
@@ -19,7 +20,13 @@ export const splitBlockCommand = (
dispatch: ((args?: any) => any) | undefined;
}) => {
if (dispatch) {
- return splitBlockTr(state.tr, posInBlock, keepType, keepProps);
+ return splitBlockTr(
+ state.tr,
+ posInBlock,
+ keepType,
+ keepProps,
+ keepChildren,
+ );
}
return true;
@@ -31,6 +38,7 @@ export const splitBlockTr = (
posInBlock: number,
keepType?: boolean,
keepProps?: boolean,
+ keepChildren?: boolean,
): boolean => {
const nearestBlockContainerPos = getNearestBlockPos(tr.doc, posInBlock);
@@ -55,7 +63,28 @@ export const splitBlockTr = (
},
];
+ // The children follow the text after the caret into the second block, as
+ // `tr.split` leaves them. With `keepChildren` they move back to the first
+ // half, within the same undo step. At the start, the whole content goes to
+ // the second block, and the children with it.
+ const children =
+ keepChildren && posInBlock !== info.contentStart
+ ? info.children
+ : undefined;
+ 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..2aca479d37 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 { layoutSchema } from "../../layoutBlocks/layoutBlocks.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,231 @@ describe("Test updateBlock minimal steps", () => {
expect(() => editor._tiptapEditor.state.doc.check()).not.toThrow();
});
});
+
+// Changing a block's type across the content/layout block 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 getLayoutEditor = setupTestEnv({
+ schema: layoutSchema,
+ 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. The
+ // rebuilt block keeps its ID, so comments and other references to it stay
+ // valid. Before, it got a new ID; the first and the last test check this.
+ it("Moves inline content into a child paragraph when becoming a layout block", () => {
+ const editor = getLayoutEditor();
+ 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).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 layout block's children when there is no content to carry", () => {
+ const editor = getLayoutEditor();
+ 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 callouts for the
+ // pair's `min: 2`, each with the one empty block a callout needs.
+ const block = editor.document[1] as any;
+ expect(block.type).toBe("pair");
+ expect(block.children.map((child: any) => child.type)).toEqual([
+ "callout",
+ "callout",
+ ]);
+ for (const callout of block.children) {
+ expect(callout.children.map((child: any) => child.content)).toEqual([[]]);
+ }
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("Puts carried content before existing children", () => {
+ const editor = getLayoutEditor();
+ 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 layout block", () => {
+ const editor = getLayoutEditor();
+ // Table content isn't an inline array, so there is no sensible paragraph to
+ // wrap it in. It's dropped, and the layout block 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 = getLayoutEditor();
+ 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 layout block", () => {
+ const editor = getLayoutEditor();
+ 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 a layout block's children when changing between layout block types", () => {
+ const editor = getLayoutEditor();
+ editor.transact((tr) => updateBlock(tr, "callout-0", { type: "box" }));
+
+ // Neither layout block holds content of its own, so the children move across
+ // untouched rather than being re-seeded.
+ const block = editor.document[4] as any;
+ expect(block.type).toBe("box");
+ expect(block.children.map((child: any) => child.id)).toEqual([
+ "callout-child",
+ ]);
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+
+ it("Keeps a layout block's children alongside the content it gains", () => {
+ const editor = getLayoutEditor();
+ editor.transact((tr) =>
+ updateBlock(tr, "callout-0", {
+ type: "heading",
+ content: "Now a heading",
+ }),
+ );
+
+ // The layout block 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 layout block's props without rebuilding it", () => {
+ const editor = getLayoutEditor();
+ editor.transact((tr) =>
+ updateBlock(tr, "callout-0", { props: { flavor: "warning" } }),
+ );
+
+ // No shape change, so this is an in-place attribute update: the layout block
+ // 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 layout block's children and invents no content when becoming a block", () => {
+ const editor = getLayoutEditor();
+ editor.transact((tr) =>
+ updateBlock(tr, "callout-0", { type: "paragraph" }),
+ );
+
+ const block = editor.document[4] as any;
+ expect(block.type).toBe("paragraph");
+ expect(block.id).toBe("callout-0");
+ 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..59e8d93bb9 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,
+ isLayoutNode,
+} 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,58 @@ 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 layout block'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 (isLayoutNode(newNodeType)) {
+ children.unshift({ type: "paragraph", content: existingBlock.content });
+ }
+ }
+
const replacementNode = blockToNode(
{
- children: existingBlock.children, // if no children are passed in, use existing children
+ // The rebuilt block keeps its id, so references to it stay valid.
+ id: existingBlock.id,
+ ...(content ? { content } : {}),
+ // Omit empty children so a new layout block 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 +196,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 +214,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 +243,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 +267,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 +552,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 +563,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. (Layout blocks 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 +671,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/layoutBlocks/fixLayoutBlock.ts b/packages/core/src/api/blockManipulation/layoutBlocks/fixLayoutBlock.ts
new file mode 100644
index 0000000000..aee015d6d9
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/layoutBlocks/fixLayoutBlock.ts
@@ -0,0 +1,173 @@
+import { Fragment, type Node, Slice } from "prosemirror-model";
+import { type Transaction } from "prosemirror-state";
+import { ReplaceAroundStep } from "prosemirror-transform";
+
+import {
+ isLayoutNode,
+ isNamedOnly,
+ minChildren,
+} from "../../../schema/blocks/children.js";
+import { getNodeById } from "../../nodeUtil.js";
+
+/**
+ * Whether `node` is a layout block child the user has emptied out: a layout block
+ * (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 isEmptyLayoutChild(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 (isLayoutNode(node.type)) {
+ if (node.childCount === 1) {
+ return isEmptyLayoutChild(node.firstChild!);
+ }
+ // A layout block left with no children at all: its last child was removed
+ // and no fill happened on the way. A `min: 0` layout block in this state is
+ // valid and stays; any other is broken structure.
+ if (node.childCount === 0) {
+ return minChildren(node.type.spec.blockConfig?.children) >= 1;
+ }
+ return false;
+ }
+ return false;
+}
+
+/**
+ * Repairs the layout block at `layoutBlockPos` after children were (re)moved from
+ * it: drops the ones the user emptied, and dissolves the layout block when too
+ * few are left for it to mean anything (a column list with one column is just
+ * that column's blocks).
+ *
+ * A layout block that only exists inside another layout block (a column) is left to
+ * its parent, which is the thing that decides whether it still belongs.
+ *
+ * @param layoutBlockPos The position just before the layout block node.
+ * @internal
+ */
+export function fixLayoutBlock(tr: Transaction, layoutBlockPos: number) {
+ const layoutBlock = tr.doc.resolve(layoutBlockPos).nodeAfter;
+ if (!layoutBlock || !isLayoutNode(layoutBlock.type)) {
+ throw new Error(
+ "Invalid layoutBlockPos: does not point to a layout block node.",
+ );
+ }
+
+ // A `namedOnly` layout block (a column) keeps existing: it is its parent's
+ // decision whether the layout block still belongs, and ProseMirror pads it
+ // back up to its minimum when children are removed.
+ if (isNamedOnly(layoutBlock.type)) {
+ return;
+ }
+
+ // A layout block that does not dissolve keeps its empty children, and stays
+ // when too few are left: ProseMirror fills it back up to its minimum with
+ // empty children (`children.dissolve`).
+ const children = layoutBlock.type.spec.blockConfig?.children;
+ if (children && "dissolve" in children && children.dissolve === false) {
+ return;
+ }
+
+ const min = minChildren(layoutBlock.type.spec.blockConfig?.children);
+ const survivors: { node: Node; offset: number }[] = [];
+ const emptied: { from: number; to: number }[] = [];
+ layoutBlock.forEach((child, offset) => {
+ if (!isEmptyLayoutChild(child)) {
+ survivors.push({ node: child, offset });
+ } else if (isLayoutNode(child.type)) {
+ const from = layoutBlockPos + 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 layout block 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 = layoutBlockPos + 1 + offset;
+ // A `namedOnly` survivor is lifted out of two wrappers at once - the column
+ // and the layout block - and any other survivor out of just the layout block.
+ const gap = isNamedOnly(survivor.type)
+ ? { from: survivorPos + 1, to: survivorPos + survivor.nodeSize - 1 }
+ : { from: survivorPos, to: survivorPos + survivor.nodeSize };
+
+ tr.step(
+ new ReplaceAroundStep(
+ layoutBlockPos,
+ layoutBlockPos + layoutBlock.nodeSize,
+ gap.from,
+ gap.to,
+ Slice.empty,
+ 0,
+ // Not a pure unwrap: emptied siblings sit outside the gap and go with
+ // the layout block, 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 layout block 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(
+ layoutBlockPos,
+ layoutBlockPos + layoutBlock.nodeSize,
+ Fragment.from(replacement),
+ );
+}
+
+/**
+ * Runs {@link fixLayoutBlock} on each of the given layout blocks, looked up by ID
+ * in `tr`'s current doc. Layout blocks are repaired deepest-first so that an
+ * inner repair (e.g. a column emptying out) is observed by the outer
+ * layout block's repair (e.g. its columnList unwrapping) in the same pass.
+ * Layout blocks that no longer exist by the time their turn comes are skipped —
+ * an earlier repair may have removed them.
+ */
+export function fixLayoutBlocksById(
+ tr: Transaction,
+ layoutBlocks: { id: string; depth: number }[],
+) {
+ [...layoutBlocks]
+ .sort((a, b) => b.depth - a.depth)
+ .forEach(({ id }) => {
+ const target = getNodeById(id, tr.doc);
+ if (target && isLayoutNode(target.node.type)) {
+ fixLayoutBlock(tr, target.posBeforeNode);
+ }
+ });
+}
diff --git a/packages/core/src/api/blockManipulation/layoutBlocks/layoutBlocks.browser.test.ts b/packages/core/src/api/blockManipulation/layoutBlocks/layoutBlocks.browser.test.ts
new file mode 100644
index 0000000000..0188dc0f6d
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/layoutBlocks/layoutBlocks.browser.test.ts
@@ -0,0 +1,293 @@
+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 { layoutSchema } from "./layoutBlocks.fixture.js";
+
+// Keymap tests for layout blocks, split off from the node-environment
+// `layoutBlocks.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 = layoutSchema;
+
+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", () => {
+ // The previous sibling's children end in a layout block, which Tab used to
+ // throw on ("Cannot join blockContainer onto callout"). The block now nests
+ // after the layout block.
+ it("Tab after a layout block at the end of the previous sibling's children nests after it", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "p-0",
+ type: "paragraph",
+ content: "Paragraph 0",
+ children: [
+ {
+ id: "c-0",
+ type: "callout",
+ children: [{ type: "paragraph", content: "In callout" }],
+ },
+ ],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+
+ await pressKey("Tab", { block: "after", placement: "start" });
+
+ expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([
+ "c-0",
+ "after",
+ ]);
+ expect(editor.getTextCursorPosition().block.id).toBe("after");
+ });
+
+ it("Enter on an empty last child escapes the layout block", 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-layout-block stays inside", async () => {
+ // The escape only fires at the end of the layout block. 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 layout block'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 layout block 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 layout block 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 layout block'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/createSpec.browser.test.ts`.
+describe("children conversion", () => {
+ it("flattens layout blocks 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/layoutBlocks/layoutBlocks.fixture.ts b/packages/core/src/api/blockManipulation/layoutBlocks/layoutBlocks.fixture.ts
new file mode 100644
index 0000000000..e9270b6c0e
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/layoutBlocks/layoutBlocks.fixture.ts
@@ -0,0 +1,145 @@
+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 frameDiv = () => {
+ const dom = document.createElement("div");
+ return { dom, slot: dom };
+};
+
+const Callout = createBlockSpec(
+ {
+ type: "callout" as const,
+ propSchema: {
+ flavor: {
+ default: "tip",
+ values: ["tip", "info", "warning", "success"],
+ },
+ },
+ content: "none",
+ layoutOnly: true,
+ },
+ { renderFrame: frameDiv },
+)();
+
+const Grid = createBlockSpec(
+ {
+ type: "grid" as const,
+ propSchema: {},
+ content: "none",
+ layoutOnly: true,
+ children: {
+ allow: ["gridCell"],
+ min: 2,
+ },
+ },
+ { renderFrame: frameDiv },
+)();
+
+const GridCell = createBlockSpec(
+ {
+ type: "gridCell" as const,
+ propSchema: {},
+ content: "none",
+ layoutOnly: true,
+ placeable: "namedOnly",
+ },
+ { renderFrame: frameDiv },
+)();
+
+// A second layout block of any blocks, for conversions between layout block types.
+const Box = createBlockSpec(
+ {
+ type: "box" as const,
+ propSchema: {},
+ content: "none",
+ layoutOnly: true,
+ },
+ { renderFrame: frameDiv },
+)();
+
+// A layout block that requires two callouts, so dropping below `min` dissolves
+// it into the survivor. (`min` needs an `allow` list: a layout block of any
+// blocks always holds at least one.)
+const Pair = createBlockSpec(
+ {
+ type: "pair" as const,
+ propSchema: {},
+ content: "none",
+ layoutOnly: true,
+ children: { allow: ["callout"], min: 2 },
+ },
+ { renderFrame: frameDiv },
+)();
+
+// A tab set: its tabs are content, not layout, so it does not dissolve. An
+// empty tab stays, and the set stays when too few tabs are left.
+const Tabs = createBlockSpec(
+ {
+ type: "tabs" as const,
+ propSchema: {},
+ content: "none",
+ layoutOnly: true,
+ children: { allow: ["tab"], min: 1, dissolve: false },
+ },
+ { renderFrame: frameDiv },
+)();
+
+const Tab = createBlockSpec(
+ {
+ type: "tab" as const,
+ propSchema: {},
+ content: "none",
+ layoutOnly: true,
+ placeable: "namedOnly",
+ },
+ { renderFrame: frameDiv },
+)();
+
+// 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 layoutSchema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ callout: Callout,
+ alert: Alert,
+ grid: Grid,
+ gridCell: GridCell,
+ pair: Pair,
+ box: Box,
+ tabs: Tabs,
+ tab: Tab,
+ } as const,
+});
diff --git a/packages/core/src/api/blockManipulation/layoutBlocks/layoutBlocks.test.ts b/packages/core/src/api/blockManipulation/layoutBlocks/layoutBlocks.test.ts
new file mode 100644
index 0000000000..256fccc97f
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/layoutBlocks/layoutBlocks.test.ts
@@ -0,0 +1,783 @@
+// @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 { layoutSchema } from "./layoutBlocks.fixture.js";
+
+type PartialBlock = (typeof layoutSchema)["PartialBlock"];
+
+// Document-model behaviour of layout 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
+// `layoutBlocks.browser.test.ts`.
+
+const schema = layoutSchema;
+
+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: "callout", 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: "layoutBlock" }], "p-1", "after");
+ const children = editor.getBlock("layoutBlock")!.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 layout block 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 layout blocks", () => {
+ 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 layout block'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
+ // layout blocks placeable anywhere, so a namedOnly block only fits where a
+ // parent names it explicitly: not at the root, and not under a wildcard
+ // layout block.
+ 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("layout block keyboard defaults", () => {
+ // A layout block's children can't be outdented, so its defaults say so: an
+ // empty last child leaves the layout block instead.
+ it("defaults to leaving the layout block 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("layout block nodes", () => {
+ // No layout block 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 layout block and wraps the content in a spurious
+ // `blockGroup`, corrupting the document.
+ it("leaves every layout block 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 layout block to after it, paste it back over itself,
+ // and the document must come back unchanged. Marking the layout block
+ // `isolating` instead re-nests the whole fragment a level too deep.
+ it.each(["callout"])("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 layout block 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 layout block 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 layout block 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 layout block 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
+ // layout block 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 layout block unwraps to", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "pair",
+ id: "pair-0",
+ children: [
+ {
+ id: "pair-a",
+ type: "callout",
+ children: [{ id: "pair-a-p", type: "paragraph", content: "A" }],
+ },
+ {
+ id: "pair-b",
+ type: "callout",
+ children: [
+ { id: "pair-b-p", type: "paragraph", content: "Survivor" },
+ ],
+ },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.setTextCursorPosition("pair-b-p", "end");
+ const offsetBefore = editor.prosemirrorState.selection.$from.parentOffset;
+
+ editor.removeBlocks(["pair-a"]);
+
+ expect(editor.getBlock("pair-0")).toBeUndefined();
+ expect(editor.getBlock("pair-b")?.type).toBe("callout");
+ expect(editor.getTextCursorPosition().block.id).toBe("pair-b-p");
+ 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 layout block is dropped even when the layout block
+ // 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 layout block 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("layout blocks that do not dissolve", () => {
+ // `tabs` sets `dissolve: false`: an empty tab is still a tab.
+ const tab = (id: string, text: string) => ({
+ type: "tab" as const,
+ id,
+ children: [{ id: `${id}-p`, type: "paragraph" as const, content: text }],
+ });
+
+ it("keeps an emptied tab when another tab changes", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "tabs",
+ id: "tabs",
+ children: [
+ {
+ type: "tab",
+ id: "a",
+ children: [
+ { id: "a-p", type: "paragraph", content: "A" },
+ { id: "a-extra", type: "paragraph", content: "A2" },
+ ],
+ },
+ tab("b", ""),
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["a-extra"]);
+
+ expect(editor.getBlock("tabs")!.children.map((t) => t.id)).toEqual([
+ "a",
+ "b",
+ ]);
+ });
+
+ it("keeps the tab set when the last tab with text is removed", () => {
+ editor.replaceBlocks(editor.document, [
+ { type: "tabs", id: "tabs", children: [tab("a", "A"), tab("b", "")] },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["a"]);
+
+ expect(editor.getBlock("tabs")!.children.map((t) => t.id)).toEqual(["b"]);
+ });
+
+ it("fills the tab set with an empty tab when its only tab is removed", () => {
+ editor.replaceBlocks(editor.document, [
+ { type: "tabs", id: "tabs", children: [tab("a", "A")] },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["a"]);
+
+ const tabs = editor.getBlock("tabs")!;
+ expect(tabs.children).toHaveLength(1);
+ expect(tabs.children[0].type).toBe("tab");
+ expect(tabs.children[0].id).not.toBe("a");
+ expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
+ });
+});
+
+describe("repair edge cases", () => {
+ // A layout block that explicitly allows being empty. `min: 0` compiles to a
+ // `*` content expression, so ProseMirror never pads it and repair leaves
+ // it alone. (Only an `allow` list can set it: a layout block of any blocks
+ // always holds at least one.)
+ const Tray = createBlockSpec(
+ {
+ type: "tray" as const,
+ propSchema: {},
+ content: "none" as const,
+ layoutOnly: true,
+ children: { allow: ["callout"], min: 0 },
+ },
+ {
+ renderFrame: () => {
+ const dom = document.createElement("div");
+ return { dom, slot: dom };
+ },
+ },
+ )();
+
+ it("keeps a `min: 0` layout block with zero children", () => {
+ const trayEditor = BlockNoteEditor.create({
+ schema: layoutSchema.extend({
+ blockSpecs: { tray: Tray },
+ }),
+ });
+ try {
+ trayEditor.replaceBlocks(trayEditor.document, [
+ {
+ type: "tray",
+ id: "t-0",
+ children: [
+ {
+ id: "t-c-0",
+ type: "callout",
+ children: [{ type: "paragraph", content: "" }],
+ },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ // Removing its only child leaves zero children, which `min: 0` allows:
+ // the tray stays instead of dissolving, and nothing is padded back.
+ trayEditor.removeBlocks(["t-c-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 layout block 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 *layout block* 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` layout block left with one surviving child", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "pair",
+ id: "s-0",
+ children: [
+ {
+ id: "s-a",
+ type: "callout",
+ children: [{ type: "paragraph", content: "A" }],
+ },
+ {
+ id: "s-b",
+ type: "callout",
+ children: [{ 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 `fixLayoutBlock`: 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 layout block", () => {
+ 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
+ // layout block (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 layout block records its ancestors with
+// `getAncestorLayoutBlocks` and hands them to `fixLayoutBlocksById`, which repairs
+// them deepest-first. Removing a block therefore repairs the whole chain it
+// sat in, not just the layout block directly holding it.
+describe("ancestor layout block repair", () => {
+ it("repairs every layout block 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: "callout",
+ children: [{ type: "paragraph", content: "Kept" }],
+ },
+ {
+ id: "s-p-1",
+ type: "callout",
+ children: [{ type: "paragraph", content: "Removed" }],
+ },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ // Blocks from two different layout blocks in one call: each layout block is
+ // recorded once and repaired on its own terms.
+ editor.removeBlocks(["c-p-0", "s-p-1"]);
+
+ // Both layout blocks 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 layout block", () => {
+ 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 layout blocks 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
+ // layout block 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/layoutBlocks/plainBlocks.test.ts b/packages/core/src/api/blockManipulation/layoutBlocks/plainBlocks.test.ts
new file mode 100644
index 0000000000..e326885c07
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/layoutBlocks/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 { layoutSchema } from "./layoutBlocks.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 = layoutSchema.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 layout 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 layoutBlock = editor.document[0];
+ expect(text(editor, layoutBlock.children[0].id)).toBe("First\nSecond");
+ expect(layoutBlock.children[1].id).toBe("body");
+ editor.prosemirrorState.doc.check();
+ });
+});
diff --git a/packages/core/src/api/blockManipulation/layoutBlocks/titledBlocks.test.ts b/packages/core/src/api/blockManipulation/layoutBlocks/titledBlocks.test.ts
new file mode 100644
index 0000000000..8b5f6937e8
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/layoutBlocks/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 { isLayoutNode } from "../../../schema/blocks/children.js";
+import { getBlockInfoAt } from "../../getBlockInfoFromPos.js";
+import { getNodeById } from "../../nodeUtil.js";
+import { layoutSchema } from "./layoutBlocks.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 layout block) is the control.
+
+const schema = layoutSchema;
+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(isLayoutNode(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 layout block", () => {
+ 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 layout block 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 layout block 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/clipboard/fromClipboard/handleFileInsertion.ts b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts
index 74af5ba031..9561927f0f 100644
--- a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts
+++ b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts
@@ -109,7 +109,7 @@ export async function handleFileInsertion<
// Gets file block corresponding to MIME type.
let fileBlockType = "file";
for (const blockSpec of Object.values(editor.schema.blockSpecs)) {
- for (const mimeType of blockSpec.implementation.meta?.fileBlockAccept ||
+ for (const mimeType of blockSpec.implementation.meta.fileBlockAccept ||
[]) {
const isFileExtension = mimeType.startsWith(".");
const file = items[i].getAsFile();
diff --git a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts
index e150af1309..d284d251bb 100644
--- a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts
+++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts
@@ -144,10 +144,10 @@ export function selectedFragmentToHTML<
// selected text instead of markdown.
const { $from, $to } = view.state.selection;
const parentBlockType = $from.parent.type.name;
- const parentBlockSpec = editor.blockImplementations[parentBlockType as any];
+ const parentBlockSpec =
+ editor.schema.blockSpecs[parentBlockType as keyof BSchema];
const isPurelyInsideCodeBlock =
- $from.sameParent($to) &&
- parentBlockSpec?.implementation.meta?.code === true;
+ $from.sameParent($to) && parentBlockSpec?.implementation.meta.code === true;
const markdown = isPurelyInsideCodeBlock
? view.state.doc.textBetween($from.pos, $to.pos)
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..0a267d1b43 100644
--- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
+++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
@@ -8,6 +8,12 @@ import {
InlineContentSchema,
StyleSchema,
} from "../../../../schema/index.js";
+import { isLayoutNode } from "../../../../schema/blocks/children.js";
+import {
+ FRAME_SLOT_ATTRIBUTE,
+ renderFrameDOM,
+} from "../../../../schema/blocks/frame.js";
+import { isDocumentFragment } from "../../../../schema/blocks/internal.js";
import { UnreachableCaseError } from "../../../../util/typescript.js";
import {
inlineContentToNodes,
@@ -176,6 +182,52 @@ export function serializeInlineContentExternalHTML<
return fragment;
}
+/** A block's external HTML, before the serializer post-processes it. */
+type BlockOutput = {
+ dom: HTMLElement | DocumentFragment;
+ contentDOM?: HTMLElement;
+ childrenDOM?: HTMLElement;
+};
+
+/**
+ * The block's output for external HTML, and the frame to put it into. The
+ * block's own `toExternalHTML` replaces the default export, frame included.
+ * By default, a layout block is its frame, with the children in the slot, and
+ * any other block is `render`'s output, which the caller puts into the
+ * block's frame (if it draws one).
+ */
+function renderBlockOutput(
+ blockImplementation: BlockImplementation,
+ block: any,
+ editor: BlockNoteEditor,
+ isLayoutBlock: boolean,
+ nestingLevel: number,
+): { output: BlockOutput; frame?: { dom: HTMLElement; slot: HTMLElement } } {
+ const external = blockImplementation.toExternalHTML?.call({}, block, editor, {
+ nestingLevel,
+ });
+ if (external) {
+ return { output: external };
+ }
+
+ const frame = renderFrameDOM(editor, block);
+ if (isLayoutBlock) {
+ if (!frame) {
+ throw new Error(
+ `Layout block "${block.type}" drew no frame. This is a bug in BlockNote.`,
+ );
+ }
+ return { output: { dom: frame.dom, childrenDOM: frame.slot } };
+ }
+
+ if (!blockImplementation.render) {
+ throw new Error(
+ `Block "${block.type}" has no \`render\`. This is a bug in BlockNote.`,
+ );
+ }
+ return { output: blockImplementation.render.call({}, block, editor), frame };
+}
+
/**
* TODO: there's still quite some logic that handles getting and filtering properties,
* we should make sure the `toExternalHTML` methods of default blocks actually handle this,
@@ -224,27 +276,27 @@ function serializeBlock<
const blockImplementation = editor.blockImplementations[block.type as any]
.implementation as BlockImplementation;
- const ret =
- blockImplementation.toExternalHTML?.call(
- {},
- { ...block, props } as any,
- editor as any,
- {
- nestingLevel,
- },
- ) ||
- blockImplementation.render.call(
- {},
- { ...block, props } as any,
- editor as any,
- );
+ const blockWithDefaults = { ...block, props, children: block.children ?? [] };
+ const isLayoutBlock = isLayoutNode(editor.pmSchema.nodes[block.type as any]);
+ const { output: ret, frame } = renderBlockOutput(
+ blockImplementation,
+ blockWithDefaults,
+ editor,
+ isLayoutBlock,
+ nestingLevel,
+ );
const elementFragment = doc.createDocumentFragment();
- if ((ret.dom as HTMLElement).classList.contains("bn-block-content")) {
+ let rootElement: HTMLElement | undefined;
+ if (!isDocumentFragment(ret.dom)) {
+ rootElement = ret.dom;
+ }
+
+ if (rootElement?.classList.contains("bn-block-content")) {
const blockContentDataAttributes = [
...attrs,
- ...Array.from((ret.dom as HTMLElement).attributes),
+ ...Array.from(rootElement.attributes),
].filter(
(attr) =>
attr.name.startsWith("data") &&
@@ -256,26 +308,40 @@ 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);
+ (rootElement.firstChild! as HTMLElement).setAttribute(
+ attr.name,
+ attr.value,
+ );
}
- addAttributesAndRemoveClasses(ret.dom.firstChild! as HTMLElement);
+ addAttributesAndRemoveClasses(rootElement.firstChild! as HTMLElement);
if (nestingLevel > 0) {
- (ret.dom.firstChild! as HTMLElement).setAttribute(
+ (rootElement.firstChild! as HTMLElement).setAttribute(
"data-nesting-level",
nestingLevel.toString(),
);
}
- elementFragment.append(...Array.from(ret.dom.childNodes));
+ elementFragment.append(...Array.from(rootElement.childNodes));
} else {
elementFragment.append(ret.dom);
+ if (isLayoutBlock) {
+ // Like a regular block's type marker (`data-content-type`), a
+ // layout block's markers stay out of external HTML: it parses back only
+ // through the block's `parse`, like any other block. Its props stay as
+ // `data-*` attributes. This mirrors the unwrapping of a regular block's
+ // `.bn-block-content` above: the spec's hooks produce the editor's
+ // structure with its markers, and this serializer strips them.
+ // TODO: consider not adding the markers in the first place (only the
+ // node view and the internal serializer need them), instead of adding
+ // and then stripping them. If so, change it for regular blocks too
+ // (`wrapInBlockStructure`), so both kinds of block keep one rule.
+ rootElement?.removeAttribute("data-node-type");
+ rootElement?.removeAttribute("data-id");
+ ret.childrenDOM?.removeAttribute(FRAME_SLOT_ATTRIBUTE);
+ }
if (nestingLevel > 0) {
- (ret.dom as HTMLElement).setAttribute(
- "data-nesting-level",
- nestingLevel.toString(),
- );
+ rootElement?.setAttribute("data-nesting-level", nestingLevel.toString());
}
}
@@ -301,15 +367,22 @@ 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));
}
}
+ // Where the block's children go: where the block placed them, else in the
+ // slot of the frame around its content.
+ let childrenDOM = ret.childrenDOM;
+ if (frame) {
+ frame.slot.append(elementFragment);
+ elementFragment.append(frame.dom);
+ childrenDOM = frame.slot;
+ }
+
let listType = undefined;
if (orderedListItemBlockTypes.has(block.type!)) {
listType = "OL";
@@ -362,18 +435,20 @@ function serializeBlock<
}
}
- if ("childrenDOM" in ret && ret.childrenDOM) {
+ if (childrenDOM) {
// block specifies where children should go (e.g. toggle blocks
- // place children inside )
- ret.childrenDOM.append(childFragment);
+ // place children inside , or a frame's slot)
+ childrenDOM.append(childFragment);
} else if (
- editor.pmSchema.nodes[block.type as any].isInGroup("blockContent")
+ editor.pmSchema.nodes[block.type as any].isInGroup("blockContent") ||
+ !ret.contentDOM
) {
- // default "blockContainer" style blocks are flattened (no "nested block" support) for externalHTML, so append the child fragment to the outer fragment
+ // default "blockContainer" style blocks are flattened (no "nested block" support) for externalHTML, so append the child fragment to the outer fragment.
+ // So does a layout block whose own export places its children nowhere.
fragment.append(childFragment);
} else {
// for columns / column lists, do use nesting
- ret.contentDOM?.append(childFragment);
+ ret.contentDOM.append(childFragment);
}
}
}
diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts
index 0f890b77ab..3a5ed1e357 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 { isLayoutNode } from "../../../../schema/blocks/children.js";
import { UnreachableCaseError } from "../../../../util/typescript.js";
import {
inlineContentToNodes,
@@ -14,6 +15,11 @@ import {
} from "../../../nodeConversions/blockToNode.js";
import { nodeToCustomInlineContent } from "../../../nodeConversions/nodeToBlock.js";
+import { setBlockColorAttributes } from "../../../../pm-nodes/BlockContainer.js";
+import {
+ markFrameSlot,
+ renderFrameDOM,
+} from "../../../../schema/blocks/frame.js";
export function serializeInlineContentInternalHTML<
BSchema extends BlockSchema,
I extends InlineContentSchema,
@@ -150,12 +156,37 @@ function serializeBlock<
const children = block.children || [];
const impl = editor.blockImplementations[block.type as any].implementation;
+ const fullBlock = { ...block, props, children } as any;
+
+ if (isLayoutNode(editor.pmSchema.nodes[block.type as any])) {
+ // A layout block is its frame. `renderFrameDOM` stamps the frame's root with
+ // the block's attributes and marks the slot, which holds the children, so
+ // the round-trip parse rule reads only the slot and not the chrome around
+ // it.
+ const frame = renderFrameDOM(editor, fullBlock);
+ if (!frame) {
+ throw new Error(
+ `Layout block "${block.type}" drew no frame. This is a bug in BlockNote.`,
+ );
+ }
+ if (children.length > 0) {
+ frame.slot.append(serializeBlocks(editor, children, serializer, options));
+ }
+ return frame.dom;
+ }
+
+ // Every block but a layout block declares `render` (`validateRenderHooks`).
+ if (!impl.render) {
+ throw new Error(
+ `Block "${block.type}" has no \`render\`. This is a bug in BlockNote.`,
+ );
+ }
const ret = impl.render.call(
{
renderType: "dom",
props: undefined,
},
- { ...block, props, children } as any,
+ fullBlock,
editor as any,
);
@@ -170,22 +201,6 @@ function serializeBlock<
ret.contentDOM.appendChild(ic);
}
- const pmType = editor.pmSchema.nodes[block.type as any];
-
- if (pmType.isInGroup("bnBlock")) {
- if (block.children && block.children.length > 0) {
- const fragment = serializeBlocks(
- editor,
- block.children,
- serializer,
- options,
- );
-
- ret.contentDOM?.append(fragment);
- }
- return ret.dom;
- }
-
// wrap the block in a blockContainer
const bc = BC_NODE.spec?.toDOM?.(
BC_NODE.create({
@@ -196,12 +211,27 @@ function serializeBlock<
dom: HTMLElement;
contentDOM?: HTMLElement;
};
+ if (bc.contentDOM) {
+ setBlockColorAttributes(bc.contentDOM, props);
+ }
- bc.contentDOM?.appendChild(ret.dom);
+ // The block's content and children go into the blockContainer, or into the
+ // slot of the frame around them. The DOM render context lets interactive
+ // frames export without browser view state.
+ let contentDOM = bc.contentDOM;
+ const frame = renderFrameDOM(editor, fullBlock);
+ if (frame) {
+ // Marks where the block's content and children are, for the parser: the
+ // rest of the frame is chrome, not document content.
+ markFrameSlot(frame.slot);
+ bc.contentDOM?.appendChild(frame.dom);
+ contentDOM = frame.slot;
+ }
+ contentDOM?.appendChild(ret.dom);
- if (block.children && block.children.length > 0) {
- bc.contentDOM?.appendChild(
- serializeBlocksInternalHTML(editor, block.children, serializer, options),
+ if (children.length > 0) {
+ contentDOM?.appendChild(
+ serializeBlocksInternalHTML(editor, children, serializer, options),
);
}
return bc.dom;
diff --git a/packages/core/src/api/getBlockInfoFromPos.test.ts b/packages/core/src/api/getBlockInfoFromPos.test.ts
index 3e45c14330..c98bc513c7 100644
--- a/packages/core/src/api/getBlockInfoFromPos.test.ts
+++ b/packages/core/src/api/getBlockInfoFromPos.test.ts
@@ -1,10 +1,13 @@
+import { layoutSchema } from "./blockManipulation/layoutBlocks/layoutBlocks.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 {
+ getAncestorLayoutBlocks,
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,141 @@ describe("docToBlocks round trip with suggested deletions", () => {
expect(new Set(ids).size).toBe(ids.length);
});
});
+
+describe("block info for layout blocks", () => {
+ let editor: BlockNoteEditor<
+ typeof layoutSchema.blockSchema,
+ typeof layoutSchema.inlineContentSchema,
+ typeof layoutSchema.styleSchema
+ >;
+ beforeEach(() => {
+ editor = BlockNoteEditor.create({ schema: layoutSchema });
+ });
+ 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 layout block children", () => {
+ // Regression: `getParentBlockInfo` used to skip the layout block level for
+ // layout block 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 layout block 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 layout block 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 = getAncestorLayoutBlocks(tr.doc, deep.posBeforeNode);
+
+ // Only the layout block nodes: the `blockGroup`/`blockContainer` levels
+ // between them are not layout blocks and must not be repaired.
+ expect(ancestors.map(({ id }) => id)).toEqual(["c-0", "cell-a", "g-0"]);
+ // Depths shrink outwards, which is what `fixLayoutBlocksById` sorts on.
+ expect(ancestors.map(({ depth }) => depth)).toEqual(
+ [...ancestors.map(({ depth }) => depth)].sort((a, b) => b - a),
+ );
+
+ // A top-level block has no layout block ancestors at all.
+ const trailing = getNodeById("trailing", tr.doc)!;
+ expect(getAncestorLayoutBlocks(tr.doc, trailing.posBeforeNode)).toEqual(
+ [],
+ );
+ });
+ });
+});
diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts
index a9512f0f6a..1532035687 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,
+ isLayoutNode,
+} from "../schema/blocks/children.js";
import type { BlockConfig } from "../schema/blocks/types.js";
/**
@@ -88,10 +92,12 @@ export type BlockInfo = {
/** `content.afterPos - 1`: the last position inside the content. */
contentEnd: number;
/**
- * What the content node holds: its block spec's `content`, which is what
- * the node's ProseMirror content expression was generated from (and what
- * a hand-written node's expression is checked against when the schema is
- * built).
+ * What the content node holds: its block spec's `content`, read from the
+ * `blockConfig` stored on the node's spec. For a node `createBlockSpec`
+ * builds, the node's ProseMirror content expression is generated from
+ * it. For a hand-written node (`createBlockSpecFromTiptapNode`), it is
+ * whatever the block spec declares; the node's own content expression is
+ * not checked against it.
*/
contentKind: BlockConfig["content"];
/** `content.node.childCount === 0`. */
@@ -319,7 +325,7 @@ export function getBlockInfoFromNode(node: Node, beforePos: number): BlockInfo {
afterPos: beforePos + node.nodeSize,
};
- if (node.type.isInGroup("bnBlock") && node.type.isInGroup("childContainer")) {
+ if (isLayoutNode(node.type)) {
return {
hasContent: false,
block,
@@ -452,7 +458,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 +589,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 || !isLayoutNode(child.type)) {
break;
}
info = getBlockInfoFromNode(
@@ -599,3 +599,80 @@ export function getInsertionPos(
}
return null;
}
+
+/**
+ * Resolves a block to its first leaf block: the block itself when it is not a
+ * layout block, otherwise the first leaf of its first child. Returns `null` for
+ * an empty layout block.
+ */
+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 layout blocks until it reaches a position where `nodeType` fits.
+ * `side` picks which edge of each climbed layout block to land on: `"before"` for
+ * moves that put a block above the layout blocks 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 && isLayoutNode(parent.type)) {
+ pos = side === "before" ? $pos.before() : $pos.after();
+ continue;
+ }
+ return undefined;
+ }
+}
+
+/**
+ * The layout blocks that a removal at a position can empty, outermost last,
+ * each with its block id and resolution depth. Used to re-run layout block
+ * repair (`fixLayoutBlocksById`) after a mutation. Position-based for the same
+ * reason as `ascendToInsertablePos`: selections and mapped positions are the
+ * natural inputs.
+ *
+ * These are the layout blocks that directly hold the position, and the layout
+ * blocks that directly hold them. The walk stops at the first ancestor that is
+ * not a layout block: a removal inside a regular block's children cannot empty
+ * a layout block above that regular block, so those layout blocks stay as they
+ * are.
+ */
+export function getAncestorLayoutBlocks(
+ doc: Node,
+ pos: number,
+): { id: string; depth: number }[] {
+ const $pos = doc.resolve(pos);
+ const layoutBlocks: { id: string; depth: number }[] = [];
+ for (let depth = $pos.depth; depth > 0; depth--) {
+ const ancestor = $pos.node(depth);
+ if (!isLayoutNode(ancestor.type)) {
+ break;
+ }
+ if (ancestor.attrs.id) {
+ layoutBlocks.push({ id: ancestor.attrs.id, depth });
+ }
+ }
+ return layoutBlocks;
+}
diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts
index af5c0ba1b7..7728ecf163 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,
+ isLayoutNode,
+} 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 (isLayoutNode(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..10e011cc72 100644
--- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts
+++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts
@@ -1,63 +1,48 @@
-import { Fragment } from "@tiptap/pm/model";
+import { Fragment, Node } from "@tiptap/pm/model";
import {
BlockNoDefaults,
BlockSchema,
InlineContentSchema,
StyleSchema,
} from "../../schema/index.js";
+import {
+ isLayoutNode,
+ isNamedOnly,
+ minChildren,
+} 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 flattenLayoutBlock =
+ isLayoutNode(node.type) &&
+ (!childrenConfig ||
+ isNamedOnly(node.type) ||
+ node.childCount < minChildren(childrenConfig));
- 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 layout blocks that cannot stand alone contribute
+ // their children. Complete blocks already include their descendants.
+ if (
+ incompleteBlock ||
+ flattenLayoutBlock ||
+ !node.type.isInGroup("bnBlock")
+ ) {
+ node.forEach((child) => visit(child, flattenLayoutBlock ? 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..f1c098adac 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 { isLayoutNode } 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,45 +559,58 @@ export function prosemirrorSliceToSlicedBlocks<
blockCutAtStart: string | undefined;
blockCutAtEnd: string | undefined;
} {
- if (node.type.name !== "blockGroup") {
+ // Both `blockGroup` and layout block nodes (columnList, column, callout,
+ // ...) hold block children directly, so both can be processed here.
+ if (node.type.name !== "blockGroup" && !isLayoutNode(node.type)) {
throw new Error("unexpected");
}
const blocks: Block[] = [];
let blockCutAtStart: string | undefined;
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,
- );
- }
-
+ // Each child is a block's node: a `blockContainer` for a regular block,
+ // or a layout block node (columnList, column, callout, ...) for a layout block
+ // block.
+ node.forEach((blockNode, _offset, index) => {
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 isLayoutBlock = isLayoutNode(blockNode.type);
+ if (!isLayoutBlock) {
+ if (blockNode.type.name !== "blockContainer") {
throw new Error("unexpected");
}
+ if (blockNode.childCount === 0) {
+ return;
+ }
+ if (blockNode.childCount > 2) {
+ throw new Error(
+ "unexpected, blockContainer.childCount: " + blockNode.childCount,
+ );
+ }
+ }
+
+ const omittedParent =
+ !isLayoutBlock && blockNode.firstChild!.type.name === "blockGroup";
+ if (omittedParent && !isFirstBlock) {
+ throw new Error("unexpected");
+ }
+
+ // Open layout blocks and regular parents whose content was cut away both
+ // contribute their selected children, without their own wrapper.
+ if (
+ omittedParent ||
+ (isLayoutBlock &&
+ ((isFirstBlock && openStart > 0) || (isLastBlock && openEnd > 0)))
+ ) {
const ret = processNode(
- blockContainer.firstChild!,
- Math.max(0, openStart - 1),
+ isLayoutBlock ? blockNode : blockNode.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,9 +618,16 @@ export function prosemirrorSliceToSlicedBlocks<
return;
}
- const block = nodeToBlock(blockContainer, slice.content.firstChild!);
+ if (isLayoutBlock) {
+ blocks.push(
+ nodeToBlock(blockNode, slice.content.firstChild!),
+ );
+ return;
+ }
+
+ const block = nodeToBlock(blockNode, slice.content.firstChild!);
const childGroup =
- blockContainer.childCount > 1 ? blockContainer.child(1) : undefined;
+ blockNode.childCount > 1 ? blockNode.child(1) : undefined;
let childBlocks: Block[] = [];
if (childGroup) {
diff --git a/packages/core/src/blocks/Heading/block.ts b/packages/core/src/blocks/Heading/block.ts
index 6b14204cc8..572468d29a 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,29 @@ 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",
+ // A closed toggle keeps its hidden children.
+ splitKeepsChildren: !open,
+ emptyChildEnter: open ? "stay" : "outdent",
+ };
+ }
+ : undefined,
parse(e) {
if (allowToggleHeadings && e.tagName === "DETAILS") {
const summary = e.querySelector(":scope > summary");
@@ -126,19 +158,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 +201,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 +218,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..b516cf9d9e 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,24 @@ 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",
+ // A closed toggle keeps its hidden children.
+ splitKeepsChildren: !open,
+ 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 +89,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 +117,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/Table/block.ts b/packages/core/src/blocks/Table/block.ts
index eb464373c0..6f36453048 100644
--- a/packages/core/src/blocks/Table/block.ts
+++ b/packages/core/src/blocks/Table/block.ts
@@ -5,12 +5,12 @@ import { NodeView } from "prosemirror-view";
import { createExtension } from "../../editor/BlockNoteExtension.js";
import {
+ applyDataAttributes,
BlockConfig,
createBlockSpecFromTiptapNode,
TableContent,
} from "../../schema/index.js";
import { mergeCSSClasses } from "../../util/browser.js";
-import { camelToDataKebab } from "../../util/string.js";
import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js";
import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js";
import { defaultProps } from "../defaultProps.js";
@@ -277,15 +277,7 @@ const TiptapTableNode = Node.create({
return false;
}
- for (const [propName, propSpec] of Object.entries(tablePropSchema)) {
- const attrName = camelToDataKebab(propName);
- const value = updatedNode.attrs[propName];
- if (value !== propSpec.default) {
- this.dom.setAttribute(attrName, String(value));
- } else {
- this.dom.removeAttribute(attrName);
- }
- }
+ applyDataAttributes(this.dom, updatedNode.attrs, tablePropSchema);
return true;
}
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..dcc0be6dcd
--- /dev/null
+++ b/packages/core/src/editor/blockColors.browser.test.ts
@@ -0,0 +1,127 @@
+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(root: HTMLElement) {
+ const child = root.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 root = mount(type);
+
+ expect(getComputedStyle(childContent(root)).color).toBe(RED);
+ expect(backgroundBehind(childContent(root))).toBe(BLUE_BACKGROUND);
+ });
+
+ it("follow changes to the block's props", () => {
+ const root = mount(type);
+
+ editor!.updateBlock("parent", {
+ props: { textColor: "green", backgroundColor: "default" },
+ });
+
+ expect(getComputedStyle(childContent(root)).color).toBe(GREEN);
+ expect(backgroundBehind(childContent(root))).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..da004dad89 100644
--- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts
+++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
@@ -62,7 +62,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 layout blocks own their ID; titled blocks use blockContainer.
+ ...Object.values(editor.schema.blockSpecs)
+ .filter((spec) => spec.config.layoutOnly === true)
+ .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..60f04768a4 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 layout block (`layoutOnly: true`), and an exporter
+// whose mappings return strings.
+const box = createBlockSpec(
+ {
+ type: "box",
+ propSchema: {},
+ content: "none",
+ layoutOnly: true,
+ },
+ {
+ renderFrame: () => {
+ const dom = document.createElement("div");
+ return { dom, slot: 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 layout 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..07d8647c7c 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,27 @@ 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 layout block'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 layout blocks (`createBlockSpec` doesn't keep `layoutOnly: true` in the
+ // config type yet).
+ const spec = this.schema.blockSpecs[block.type];
+ if (spec !== undefined && spec.config.layoutOnly === true) {
+ throw new Error(
+ `The mapping for layout 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..b918ed4a1d 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. Layout 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..9407fd6139
--- /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 ec7904dc35..57cf10d343 100644
--- a/packages/core/src/extensions/SideMenu/SideMenu.ts
+++ b/packages/core/src/extensions/SideMenu/SideMenu.ts
@@ -111,6 +111,10 @@ export const SideMenuExtension = createExtension(
| { exit: Point; timer: ReturnType }
| undefined;
+ function isDraggable(type: string) {
+ return editor.schema.blockSpecs[type].implementation.meta.draggable;
+ }
+
function clearIntent() {
if (intent) {
clearTimeout(intent.timer);
@@ -180,6 +184,7 @@ export const SideMenuExtension = createExtension(
editor.prosemirrorView,
target,
point,
+ isDraggable,
editor.isWithinEditor,
hoverMargin,
);
diff --git a/packages/core/src/extensions/SideMenu/blockDOM.ts b/packages/core/src/extensions/SideMenu/blockDOM.ts
deleted file mode 100644
index 2a9132280f..0000000000
--- a/packages/core/src/extensions/SideMenu/blockDOM.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import type { EditorView } from "prosemirror-view";
-
-// FIXME: this file finds blocks through their rendered DOM (selectors,
-// `closest`). Resolving through ProseMirror instead would drop the selectors:
-// `view.posAtDOM` for the block an element is in, the document for a block's
-// children, and `view.nodeDOM` for their boxes. Check `posAtDOM` inside node
-// views and nested editors first. The same applies to
-// `getDraggableBlockFromElement`.
-
-// 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"])';
-
-/**
- * Returns the root element of the block of this editor that the element is
- * in, if there is one.
- */
-export function getBlockElement(
- element: Element,
- view: Pick,
-): Element | undefined {
- // Embedded editors own their blocks, even though their DOM is inside ours.
- if (element.closest(".bn-editor") !== view.dom) {
- return undefined;
- }
- const block = element.closest(BLOCK_SELECTOR);
- return block && block !== view.dom && view.dom.contains(block)
- ? block
- : undefined;
-}
-
-/**
- * Returns the child blocks of a block element: its nested blocks, or the
- * children of a container. The blocks of an editor nested in the block are
- * not its children: they belong to that editor.
- */
-export function getDirectChildBlocks(container: Element): Element[] {
- const editor = container.closest(".bn-editor");
- const children: Element[] = [];
- for (const child of container.querySelectorAll(BLOCK_SELECTOR)) {
- if (
- child.parentElement?.closest(BLOCK_SELECTOR) === container &&
- child.closest(".bn-editor") === editor
- ) {
- children.push(child);
- }
- }
- return children;
-}
diff --git a/packages/core/src/extensions/SideMenu/blockDropHandling.ts b/packages/core/src/extensions/SideMenu/blockDropHandling.ts
index 46c38002f1..fc3293a51e 100644
--- a/packages/core/src/extensions/SideMenu/blockDropHandling.ts
+++ b/packages/core/src/extensions/SideMenu/blockDropHandling.ts
@@ -219,10 +219,15 @@ export function registerBlockDropHandlers(
"text/html",
).body;
+ // Into the type of the blocks' parent, when this editor has it (see
+ // `dragStart`): a block that only this parent can hold, e.g. a column or
+ // the step of a stepper, would otherwise be wrapped in a new copy of it.
+ const { nodes } = view.state.schema;
+ const parentType =
+ nodes[event.dataTransfer!.getData("blocknote/parent")] ??
+ nodes["blockGroup"];
const parser = DOMParser.fromSchema(view.state.schema);
- const node = parser.parse(element, {
- topNode: view.state.schema.nodes["blockGroup"].create(),
- });
+ const node = parser.parse(element, { topNode: parentType.create() });
view.dragging = {
slice: new Slice(node.content, 0, 0),
diff --git a/packages/core/src/extensions/SideMenu/blockGeometry.ts b/packages/core/src/extensions/SideMenu/blockGeometry.ts
index 3891af4812..122c04c1d3 100644
--- a/packages/core/src/extensions/SideMenu/blockGeometry.ts
+++ b/packages/core/src/extensions/SideMenu/blockGeometry.ts
@@ -1,4 +1,4 @@
-import { BLOCK_SELECTOR, getDirectChildBlocks } from "./blockDOM.js";
+import { BLOCK_SELECTOR, getDirectChildBlocks } from "../blockDOM.js";
export type BlockRect = {
top: number;
diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts
index 0848bd3f99..f90f2cf55a 100644
--- a/packages/core/src/extensions/SideMenu/dragging.ts
+++ b/packages/core/src/extensions/SideMenu/dragging.ts
@@ -61,33 +61,23 @@ function setDragImage(view: EditorView, from: number, to = from) {
to += view.state.doc.resolve(from + 1).node().nodeSize;
}
- // Parent element is cloned to remove all unselected children without affecting the editor content.
- const parentClone = view.domAtPos(from).node.cloneNode(true) as Element;
- const parent = view.domAtPos(from).node as Element;
-
- const getElementIndex = (parentElement: Element, targetElement: Element) =>
- Array.prototype.indexOf.call(parentElement.children, targetElement);
-
- const firstSelectedBlockIndex = getElementIndex(
- parent,
- // Expects from position to be just before the first selected block.
- view.domAtPos(from + 1).node.parentElement!,
- );
- const lastSelectedBlockIndex = getElementIndex(
- parent,
- // Expects to position to be just after the last selected block.
- view.domAtPos(to - 1).node.parentElement!,
- );
-
- for (let i = parent.childElementCount - 1; i >= 0; i--) {
- if (i > lastSelectedBlockIndex || i < firstSelectedBlockIndex) {
- parentClone.removeChild(parentClone.children[i]);
+ // The preview is a copy of the element that holds the dragged blocks,
+ // without its other children: a copy of each dragged block's element. The
+ // holding element can have no box of its own (`display: contents`, e.g. the
+ // slot of a React layout block), but the preview needs one.
+ const preview = view.domAtPos(from).node.cloneNode(false) as HTMLElement;
+ preview.style.removeProperty("display");
+ for (let pos = from; pos < to; pos += view.state.doc.nodeAt(pos)!.nodeSize) {
+ const blockDOM = view.nodeDOM(pos);
+ if (!blockDOM) {
+ throw new Error(`The dragged block at ${pos} has no DOM.`);
}
+ preview.appendChild(blockDOM.cloneNode(true));
}
// dataTransfer.setDragImage(element) only works if element is attached to the DOM.
unsetDragImage(view.root);
- dragImageElement = parentClone;
+ dragImageElement = preview;
// Browsers may have CORS policies which prevents iframes from being
// manipulated, so better to stay on the safe side and remove them from the
@@ -195,6 +185,13 @@ export function dragStart<
e.dataTransfer.clearData();
e.dataTransfer.setData("blocknote/html", clipboardHTML);
+ // The type of the dragged blocks' parent. The editors parse the blocks
+ // into it (see `blockDropHandling.ts`), so that a block that only this
+ // parent can hold is not wrapped in a new copy of it.
+ e.dataTransfer.setData(
+ "blocknote/parent",
+ view.state.selection.$from.parent.type.name,
+ );
e.dataTransfer.setData("text/html", externalHTML);
e.dataTransfer.setData("text/plain", plainText);
e.dataTransfer.effectAllowed = "move";
diff --git a/packages/core/src/extensions/SideMenu/sideMenuHover.ts b/packages/core/src/extensions/SideMenu/sideMenuHover.ts
index 6ee14a1710..de623e8788 100644
--- a/packages/core/src/extensions/SideMenu/sideMenuHover.ts
+++ b/packages/core/src/extensions/SideMenu/sideMenuHover.ts
@@ -1,7 +1,6 @@
import { EditorView } from "@tiptap/pm/view";
-import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js";
-import { getBlockElement } from "./blockDOM.js";
+import { getBlockElement, getDraggableBlockFromElement } from "../blockDOM.js";
import { getNestedBlockAtCursor } from "./blockGeometry.js";
export type Point = { x: number; y: number };
@@ -78,6 +77,8 @@ export function resolveHoveredBlock(
view: EditorView,
target: Element | null,
pointer: Point,
+ /** Whether blocks of this type get a drag handle. */
+ isDraggable: (type: string) => boolean,
/** Whether the element is UI that belongs to this editor (a toolbar, a menu). */
isWithinEditorUI: (element: Element) => boolean,
/** How far left or right of the editor element the pointer still counts (px). */
@@ -87,7 +88,7 @@ export function resolveHoveredBlock(
// their children do, and their gutter still needs to resolve those children.
function draggable(block: Element | undefined): HoverResolution {
const resolved = block
- ? getDraggableBlockFromElement(block, view)
+ ? getDraggableBlockFromElement(block, view, isDraggable)
: undefined;
return resolved ? { kind: "block", block: resolved } : { kind: "none" };
}
diff --git a/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts
index c9e8648861..def3509cee 100644
--- a/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts
+++ b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts
@@ -30,7 +30,7 @@ export const SourceBlockWithPreviewExtension = createExtension(
// `meta.hasPreview` (read from the spec, like the syntax-highlighting
// extension reads `meta.highlight`).
const blockHasPreview = (block: Block) =>
- !!editor.schema.blockSpecs[block.type]?.implementation?.meta?.hasPreview;
+ !!editor.schema.blockSpecs[block.type]?.implementation.meta.hasPreview;
const handleArrow =
(direction: "prev" | "next") =>
@@ -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/SyntaxHighlighting/SyntaxHighlighting.test.ts b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts
index dd89b96ad9..d5bb16aa2e 100644
--- a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts
+++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts
@@ -10,14 +10,24 @@ import {
describe("SyntaxHighlightingExtension", () => {
// The extension only reads `editor.schema.blockSpecs` and
- // `inlineContentSpecs`, so a minimal stub is enough.
+ // `inlineContentSpecs`, so a minimal stub is enough. A spec in a schema
+ // always carries its resolved `meta`.
const fakeEditor = () =>
({
schema: {
blockSpecs: {
- paragraph: { config: { type: "paragraph", content: "inline" } },
- codeBlock: { config: { type: "codeBlock", content: "plain" } },
- image: { config: { type: "image", content: "none" } },
+ paragraph: {
+ config: { type: "paragraph", content: "inline" },
+ implementation: { meta: {} },
+ },
+ codeBlock: {
+ config: { type: "codeBlock", content: "plain" },
+ implementation: { meta: {} },
+ },
+ image: {
+ config: { type: "image", content: "none" },
+ implementation: { meta: {} },
+ },
},
inlineContentSpecs: {},
},
diff --git a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts
index 877dea8428..f5d9a6243e 100644
--- a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts
+++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts
@@ -45,7 +45,7 @@ export function collectHighlightNodeTypes(schema: {
(blockSpec): blockSpec is LooseBlockSpec =>
typeof (blockSpec as LooseBlockSpec)?.config === "object" &&
(blockSpec as LooseBlockSpec).config.content === "plain" &&
- !!(blockSpec as LooseBlockSpec).implementation?.meta?.highlight,
+ !!(blockSpec as LooseBlockSpec).implementation.meta.highlight,
)
.map((blockSpec) => blockSpec.config.type);
diff --git a/packages/core/src/extensions/SyntaxHighlighting/shiki.ts b/packages/core/src/extensions/SyntaxHighlighting/shiki.ts
index a0e8cec88b..96fa7634dc 100644
--- a/packages/core/src/extensions/SyntaxHighlighting/shiki.ts
+++ b/packages/core/src/extensions/SyntaxHighlighting/shiki.ts
@@ -94,7 +94,7 @@ export function lazyShikiPlugin(
schema.blockSpecs[nodeShape.type] ||
schema.inlineContentSpecs[nodeShape.type];
- return spec?.implementation?.meta?.highlight?.(nodeShape) ?? undefined;
+ return spec?.implementation.meta.highlight?.(nodeShape) ?? undefined;
},
nodeTypes,
});
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/SideMenu/blockDOM.browser.test.ts b/packages/core/src/extensions/blockDOM.browser.test.ts
similarity index 100%
rename from packages/core/src/extensions/SideMenu/blockDOM.browser.test.ts
rename to packages/core/src/extensions/blockDOM.browser.test.ts
diff --git a/packages/core/src/extensions/blockDOM.test.ts b/packages/core/src/extensions/blockDOM.test.ts
new file mode 100644
index 0000000000..dc3281eaa3
--- /dev/null
+++ b/packages/core/src/extensions/blockDOM.test.ts
@@ -0,0 +1,178 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ getBlockElementForNode,
+ 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 blockContainer element 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 layout 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");
+});
+
+describe("getBlockElementForNode", () => {
+ const viewWith = (dom: Node | null) => ({ nodeDOM: () => dom });
+
+ it("returns a regular block's element inside its blockOuter", () => {
+ // `getBlockElement` finds the same element from inside the block.
+ const { outer, blockContainer } = regularBlock("a", "paragraph");
+ expect(getBlockElementForNode(viewWith(outer), 0, "a")).toBe(
+ blockContainer,
+ );
+ });
+
+ it("returns the node's DOM when it is the block's element", () => {
+ const frame = document.createElement("section");
+ frame.setAttribute("data-node-type", "panel");
+ frame.setAttribute("data-id", "panel");
+ expect(getBlockElementForNode(viewWith(frame), 0, "panel")).toBe(frame);
+ });
+
+ it("finds the block's element inside wrappers without its id", () => {
+ // A framework's node view: wrappers around the block's element, which
+ // holds a child block of its own.
+ const wrapper = document.createElement("div");
+ const inner = document.createElement("div");
+ const root = document.createElement("div");
+ root.setAttribute("data-node-type", "panel");
+ root.setAttribute("data-id", "panel");
+ const { outer: child } = regularBlock("child", "paragraph");
+ root.append(child);
+ inner.append(root);
+ wrapper.append(inner);
+ expect(getBlockElementForNode(viewWith(wrapper), 0, "panel")).toBe(root);
+ });
+
+ it("returns undefined when the node has no element", () => {
+ expect(getBlockElementForNode(viewWith(null), 0, "a")).toBeUndefined();
+ });
+});
diff --git a/packages/core/src/extensions/blockDOM.ts b/packages/core/src/extensions/blockDOM.ts
new file mode 100644
index 0000000000..5094c331f8
--- /dev/null
+++ b/packages/core/src/extensions/blockDOM.ts
@@ -0,0 +1,135 @@
+import type { EditorView } from "prosemirror-view";
+
+// FIXME: this file finds blocks through their rendered DOM (selectors,
+// `closest`). Resolving through ProseMirror instead would drop the selectors:
+// `view.posAtDOM` for the block an element is in, the document for a block's
+// children, and `view.nodeDOM` for their boxes. Check `posAtDOM` inside node
+// views and nested editors first.
+
+// 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"])';
+
+/**
+ * Returns the root element of the block of this editor that the element is
+ * in, if there is one.
+ */
+export function getBlockElement(
+ element: Element,
+ view: Pick,
+): Element | undefined {
+ if (!view.dom.contains(element)) {
+ 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 block = element.closest(BLOCK_SELECTOR);
+ return block && block !== view.dom && view.dom.contains(block)
+ ? block
+ : undefined;
+}
+
+/**
+ * Returns the element of the block with `id`, whose node starts at `pos`: the
+ * same element as `getBlockElement` finds from inside the block. ProseMirror's
+ * DOM for the node can be that element (a layout block's frame), or wrap it: a
+ * regular block's `.bn-block-outer`, or a framework's wrappers that don't
+ * carry the block's id (React's `display: contents` wrappers).
+ *
+ * Caution: a React layout block's frame exists only after React renders its
+ * node view, which can be after the editor's own render. Until then, this
+ * returns `undefined` for it, never another element.
+ */
+export function getBlockElementForNode(
+ view: Pick,
+ pos: number,
+ id: string,
+): Element | undefined {
+ const dom = view.nodeDOM(pos);
+ if (!(dom instanceof Element)) {
+ return undefined;
+ }
+ // The block's element comes before its child blocks, so it is the first
+ // block element: the node's DOM, or else the first one inside it (inside
+ // `.bn-block-outer`, or inside a framework's wrappers).
+ const element = dom.matches(BLOCK_SELECTOR)
+ ? dom
+ : dom.querySelector(BLOCK_SELECTOR);
+ return element?.getAttribute("data-id") === id ? element : undefined;
+}
+
+/** 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 {
+ const node = getBlockElement(element, view);
+ if (!node) {
+ 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;
+}
+
+/**
+ * Returns the child blocks of a block element: its nested blocks, or the
+ * children of a layout block. The blocks of an editor nested in the block are
+ * not its children: they belong to that editor.
+ */
+export function getDirectChildBlocks(blockElement: Element): Element[] {
+ const editor = blockElement.closest(".bn-editor");
+ const children: Element[] = [];
+ for (const child of blockElement.querySelectorAll(BLOCK_SELECTOR)) {
+ if (
+ child.parentElement?.closest(BLOCK_SELECTOR) === blockElement &&
+ child.closest(".bn-editor") === editor
+ ) {
+ children.push(child);
+ }
+ }
+ return children;
+}
diff --git a/packages/core/src/extensions/getDraggableBlockFromElement.ts b/packages/core/src/extensions/getDraggableBlockFromElement.ts
deleted file mode 100644
index cccbdfa0f0..0000000000
--- a/packages/core/src/extensions/getDraggableBlockFromElement.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { EditorView } from "prosemirror-view";
-
-// FIXME: walks the DOM to the block; could resolve through ProseMirror instead
-// (see the note in `SideMenu/blockDOM.ts`).
-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..843129ada1 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 { fixLayoutBlocksById } from "../../../api/blockManipulation/layoutBlocks/fixLayoutBlock.js";
+import { nodeToBlock } from "../../../api/nodeConversions/nodeToBlock.js";
+import { isLayoutNode } 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,
+ getAncestorLayoutBlocks,
+ 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 layout block 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 layoutBlocksToFix = getAncestorLayoutBlocks(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;
+ fixLayoutBlocksById(tr, layoutBlocksToFix);
+ 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 layout block 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 layout block's trailing slot, or out of the
+ // current layout block 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 (!isLayoutNode($pos.parent.type)) {
+ return false;
+ }
+ const $layoutBlockPos = tr.doc.resolve($pos.before());
+ // Between columns, move into the previous column. Outside a
+ // layout block, move above the closest boundary that accepts us.
+ const prevSibling = $layoutBlockPos.nodeBefore;
+ if (
+ isLayoutNode($layoutBlockPos.parent.type) &&
+ prevSibling &&
+ isLayoutNode(prevSibling.type)
+ ) {
+ target = getBlockInfoFromNode(
+ prevSibling,
+ $layoutBlockPos.pos - prevSibling.nodeSize,
+ );
+ } else {
+ insertionPos = ascendToInsertablePos(
+ tr.doc,
+ $layoutBlockPos.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 layout block 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,35 @@ export const KeyboardShortcutsExtension = Extension.create<{
if (!blockInfo.hasContent || !blockInfo.children) {
return false;
}
- const { content, children } = blockInfo;
+ const { children } = blockInfo;
+
+ // A block can have an empty children node (the `childContainer`
+ // group), but then there is 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 +421,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 +432,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 +446,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 layout block boundary. It may be inside
+ // the next sibling layout block, or follow the layout blocks 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 &&
+ isLayoutNode($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 && !isLayoutNode(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 +502,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 +547,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 +558,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 +590,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 +611,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 +618,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 +630,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 +662,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 +679,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 +712,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 +747,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 layout blocks), moves the block out
+ // (double Enter exits the layout block). 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 layout block, so the cursor could
+ // never leave a trailing layout block. Shift+Enter still adds spacing
+ // inside a layout block. 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 layout block'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 layout block'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 +819,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 +873,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,20 +980,22 @@ 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 keyboard = keyboardOf(blockInfo.block.node, state.doc);
+ const keepType = selectionAtBlockStart || keyboard.splitKeepsType;
chain()
.deleteSelection()
.command(
splitBlockCommand(
state.selection.from,
+ keepType,
selectionAtBlockStart,
- selectionAtBlockStart,
+ keyboard.splitKeepsChildren,
),
)
.scrollIntoView()
@@ -877,42 +1009,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..4f4fea61c3 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,6 +1,12 @@
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 { isLayoutNode } from "./schema/blocks/children.js";
+export { getBlockElementForNode } from "./extensions/blockDOM.js";
+export { applyLayoutBlockAttributes } from "./schema/blocks/layoutBlockAttributes.js";
+export {
+ fixLayoutBlock,
+ isEmptyLayoutChild,
+} from "./api/blockManipulation/layoutBlocks/fixLayoutBlock.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.test.ts b/packages/core/src/pm-nodes/BlockContainer.test.ts
new file mode 100644
index 0000000000..061c6552b3
--- /dev/null
+++ b/packages/core/src/pm-nodes/BlockContainer.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
+
+// `Block.css` applies a block's colors to its children from the block's own
+// element, so the node view keeps them on `.bn-block`, framed or not.
+describe("block colors on the block's element", () => {
+ const blockElement = (editor: BlockNoteEditor, id: string) =>
+ editor.domElement!.querySelector(
+ `[data-node-type="blockContainer"][data-id="${id}"]`,
+ )!;
+
+ for (const [type, props] of [
+ ["paragraph", {}],
+ ["heading", {}],
+ ["toggleListItem", {}],
+ ["heading", { isToggleable: true }],
+ ] as const) {
+ it(`puts them on a ${type} ${JSON.stringify(props)} and follows changes`, () => {
+ const editor = BlockNoteEditor.create({
+ initialContent: [
+ {
+ id: "parent",
+ type,
+ props: { ...props, textColor: "red" },
+ content: "Parent",
+ children: [{ id: "child", type: "paragraph", content: "Child" }],
+ } as any,
+ ],
+ });
+ editor.mount(document.createElement("div"));
+ try {
+ const element = () => blockElement(editor, "parent");
+ expect(element().getAttribute("data-text-color")).toBe("red");
+ expect(element().hasAttribute("data-background-color")).toBe(false);
+
+ editor.updateBlock("parent", {
+ props: { textColor: "default", backgroundColor: "blue" },
+ } as any);
+ expect(element().hasAttribute("data-text-color")).toBe(false);
+ expect(element().getAttribute("data-background-color")).toBe("blue");
+ } finally {
+ editor._tiptapEditor.destroy();
+ }
+ });
+ }
+});
+
+// As with ProseMirror's default, a change of the block's type keeps its view
+// (and its children's), unless the new type draws a frame or the old one did.
+describe("block type changes", () => {
+ function editorWithParent() {
+ const editor = BlockNoteEditor.create({
+ initialContent: [
+ {
+ id: "parent",
+ type: "paragraph",
+ content: "Parent",
+ children: [{ id: "child", type: "paragraph", content: "Child" }],
+ },
+ ],
+ });
+ editor.mount(document.createElement("div"));
+ const element = (id: string) =>
+ editor.domElement!.querySelector(
+ `[data-node-type="blockContainer"][data-id="${id}"]`,
+ );
+ return { editor, element };
+ }
+
+ it("keeps the block and its children between types without a frame", () => {
+ const { editor, element } = editorWithParent();
+ try {
+ const [parent, child] = [element("parent"), element("child")];
+ editor.updateBlock("parent", { type: "heading" });
+ editor.updateBlock("parent", { type: "bulletListItem" });
+ expect(element("parent")).toBe(parent);
+ expect(element("child")).toBe(child);
+ } finally {
+ editor._tiptapEditor.destroy();
+ }
+ });
+
+ it("draws the block again when the frame comes or goes", () => {
+ const { editor, element } = editorWithParent();
+ try {
+ const parent = element("parent");
+ editor.updateBlock("parent", { type: "toggleListItem" });
+ const toggled = element("parent")!;
+ expect(toggled).not.toBe(parent);
+ expect(toggled.querySelector(".bn-toggle-frame")).not.toBeNull();
+
+ editor.updateBlock("parent", { type: "paragraph" });
+ expect(element("parent")).not.toBe(toggled);
+ expect(element("parent")!.querySelector(".bn-toggle-frame")).toBeNull();
+ expect(element("parent")!.textContent).toContain("Child");
+ } finally {
+ editor._tiptapEditor.destroy();
+ }
+ });
+});
diff --git a/packages/core/src/pm-nodes/BlockContainer.ts b/packages/core/src/pm-nodes/BlockContainer.ts
index 86bd2ccb15..aad3429a0b 100644
--- a/packages/core/src/pm-nodes/BlockContainer.ts
+++ b/packages/core/src/pm-nodes/BlockContainer.ts
@@ -1,10 +1,54 @@
-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 {
+ blockFrameView,
+ findFrameSlot,
+ frameNodeViewHooks,
+} from "../schema/blocks/frame.js";
import { BlockNoteDOMAttributes } from "../schema/index.js";
import { mergeCSSClasses } from "../util/browser.js";
import { suggestionMarks } from "./suggestionMarks.js";
+/** The block props whose colors `Block.css` applies to a block's children. */
+const BLOCK_COLORS = [
+ ["textColor", "data-text-color"],
+ ["backgroundColor", "data-background-color"],
+] as const;
+
+function isColorSet(value: unknown): value is string {
+ return typeof value === "string" && value !== "default";
+}
+
+/** Whether a block has a text or background color other than the default. */
+function hasBlockColors(props: Record) {
+ return BLOCK_COLORS.some(([prop]) => isColorSet(props[prop]));
+}
+
+/**
+ * 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 BLOCK_COLORS) {
+ const value = props?.[prop];
+ if (isColorSet(value)) {
+ element.setAttribute(attribute, value);
+ } else {
+ element.removeAttribute(attribute);
+ }
+ }
+}
+
// Object containing all possible block attributes.
const BlockAttributes: Record = {
blockColor: "data-block-color",
@@ -49,6 +93,10 @@ export const BlockContainer = Node.create<{
return attrs;
},
+ // The frame's slot: the rest of a frame is chrome, not document
+ // content.
+ contentElement: (element) =>
+ findFrameSlot(element as HTMLElement) ?? (element as HTMLElement),
},
// Ignore `blockOuter` divs, but parse the `blockContainer` divs inside them.
{
@@ -88,4 +136,65 @@ export const BlockContainer = Node.create<{
contentDOM: block,
};
},
+
+ addNodeView() {
+ // Every block gets this node view: it draws the block's frame (or none),
+ // keeps it current, and keeps the block's colors on `.bn-block`. Without
+ // a frame, it is as fast as ProseMirror's default (`performance.test.ts`).
+ // Cast: this returns a plain ProseMirror node view, which tiptap's
+ // `NodeViewRenderer` type doesn't model.
+ return ((props: NodeViewRendererProps) => {
+ const { dom, contentDOM: block } = this.type.spec.toDOM!(props.node) as {
+ dom: HTMLElement;
+ contentDOM: HTMLElement;
+ };
+ const editor = this.options.editor;
+ // The block's frame: React draws its own, otherwise the block's
+ // vanilla `renderFrame`. A drawn frame goes inside `.bn-block`; a block
+ // without one keeps its content and children in `.bn-block` itself.
+ const frame = blockFrameView({
+ props,
+ editor,
+ blockNode: (node) => node.firstChild!,
+ unframed: block,
+ });
+ if (frame.dom !== block) {
+ block.appendChild(frame.dom);
+ }
+ // A new `.bn-block` has no color attributes, so a block with the default
+ // colors needs no DOM calls. Skipping them keeps typing in large
+ // documents fast (`performance.test.ts`).
+ const attrs = props.node.firstChild!.attrs;
+ if (hasBlockColors(attrs)) {
+ setBlockColorAttributes(block, attrs);
+ }
+
+ const nodeView: NodeView = {
+ dom,
+ contentDOM: frame.slot,
+ update(node, decorations, innerDecorations) {
+ const before = frame.node;
+ // Like ProseMirror's default, the view is rebuilt when the block's
+ // own attributes change (another block), and otherwise kept while
+ // ProseMirror updates the content and children. It is also rebuilt
+ // when the frame no longer fits, for example after a change of the
+ // block's type or props.
+ if (
+ !node.sameMarkup(before) ||
+ !frame.update(node, decorations, innerDecorations)
+ ) {
+ return false;
+ }
+ // `Block.css` applies a block's colors to its children too, from
+ // its element. They are props, so only a prop change moves them.
+ if (!node.firstChild!.sameMarkup(before.firstChild!)) {
+ setBlockColorAttributes(block, node.firstChild!.attrs);
+ }
+ return true;
+ },
+ ...frameNodeViewHooks(frame),
+ };
+ 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..f999014a5a
--- /dev/null
+++ b/packages/core/src/schema/blocks/children.test.ts
@@ -0,0 +1,288 @@
+// @vitest-environment node
+import { describe, expect, it } from "vite-plus/test";
+
+import { anyBlocksExpression, childrenContentExpression } from "./children.js";
+import type { ChildrenAllow, 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 layout block, at least one (the minimal config)",
+ { allow: "blocks" },
+ "blockGroupChild+",
+ ],
+ ["one layout block type only", { allow: ["column"], min: 2 }, "column{2,}"],
+ [
+ "one layout block type, possibly none",
+ { allow: ["column"], min: 0 },
+ "column*",
+ ],
+ [
+ "several layout block 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({ allow: "blocks" })).toBe(
+ "blockGroupChild+",
+ );
+ });
+
+ it("throws for an allow array that permits nothing", () => {
+ expect(() => childrenContentExpression({ allow: [] })).toThrow(
+ /permits nothing/,
+ );
+ });
+});
+
+type BlockFixture = {
+ content?: "none" | "inline" | "plain" | "table";
+ layoutOnly?: true;
+ // Loose on purpose: the validator tests pass shapes the type rejects.
+ children?: {
+ allow?: ChildrenAllow;
+ min?: number;
+ exclude?: string[];
+ dissolve?: boolean;
+ };
+ 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);
+
+// `exclude` is resolved against the schema: the "any blocks" group is replaced
+// by its other members, and `childrenContentExpression` takes the result.
+describe("anyBlocksExpression", () => {
+ const specs = specsWith({
+ callout: { layoutOnly: true },
+ columnList: { layoutOnly: true, children: { allow: ["column"], min: 2 } },
+ column: { layoutOnly: true, placeable: "namedOnly" },
+ }) as any;
+
+ it("is the group when nothing is excluded", () => {
+ expect(anyBlocksExpression([], specs)).toBe("blockGroupChild");
+ });
+
+ it("spells out the other members otherwise", () => {
+ expect(anyBlocksExpression(["columnList"], specs)).toBe(
+ "(blockContainer | callout)",
+ );
+ expect(anyBlocksExpression(["columnList", "callout"], specs)).toBe(
+ "blockContainer",
+ );
+ expect(
+ childrenContentExpression(
+ { allow: "blocks", exclude: ["columnList"] },
+ anyBlocksExpression(["columnList"], specs),
+ ),
+ ).toBe("(blockContainer | callout)+");
+ });
+
+ it("rejects `exclude` on an allow list at the type level", () => {
+ const config: ChildrenConfig = {
+ allow: ["column"],
+ // @ts-expect-error `exclude` is only for a layout block of any blocks
+ exclude: ["callout"],
+ };
+ expect(config.allow).toEqual(["column"]);
+ });
+});
+
+describe("validateChildrenConfigs", () => {
+ it("accepts `exclude` of a layout block that can stand anywhere", () => {
+ expect(
+ validate({
+ box: { layoutOnly: true, children: { exclude: ["callout"] } },
+ callout: { layoutOnly: true },
+ }),
+ ).not.toThrow();
+ });
+
+ it("rejects `exclude` where it can't apply", () => {
+ expect(
+ validate({
+ box: {
+ layoutOnly: true,
+ children: { allow: ["callout"], exclude: ["callout"] },
+ },
+ callout: { layoutOnly: true },
+ }),
+ ).toThrow(/only for a layout block that accepts any blocks/);
+ expect(
+ validate({
+ box: { content: "inline", children: { exclude: ["callout"] } },
+ callout: { layoutOnly: true },
+ }),
+ ).toThrow(/requires `layoutOnly: true`/);
+ expect(
+ validate({ box: { layoutOnly: true, children: { exclude: ["typo"] } } }),
+ ).toThrow(/not a configured block type/);
+ expect(
+ validate({
+ box: { layoutOnly: true, children: { exclude: ["paragraph"] } },
+ }),
+ ).toThrow(/is not a layout block/);
+ expect(
+ validate({
+ box: { layoutOnly: true, children: { exclude: ["column"] } },
+ column: { layoutOnly: true, placeable: "namedOnly" },
+ }),
+ ).toThrow(/namedOnly/);
+ });
+
+ it("accepts recursive layout blocks and named-only children", () => {
+ expect(validate({ callout: { layoutOnly: true } })).not.toThrow();
+ expect(
+ validate({
+ // gridCell is a terminating alternative to the recursive grid.
+ grid: {
+ layoutOnly: true,
+ children: { allow: ["gridCell", "grid"], min: 2 },
+ },
+ gridCell: { layoutOnly: true, placeable: "namedOnly" },
+ }),
+ ).not.toThrow();
+ });
+
+ // `min` only means something for a set of named layout block types. A
+ // layout block that accepts any blocks always holds at least one, and
+ // dissolves when it loses it.
+ it("rejects `min` on a layout block that accepts any blocks", () => {
+ for (const children of [{ allow: "blocks", min: 2 }, { min: 2 }] as const) {
+ expect(validate({ panel: { layoutOnly: true, children } })).toThrow(
+ /`children.min` needs an `allow` list/,
+ );
+ }
+ // The type rejects it too.
+ // @ts-expect-error
+ const invalid: ChildrenConfig = { allow: "blocks", min: 2 };
+ expect(invalid).toBeDefined();
+ });
+
+ it.each(["inline", "plain", "table"] as const)(
+ "rejects `layoutOnly` on a block with %s content",
+ (content) => {
+ expect(validate({ alert: { content, layoutOnly: 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 layout block",
+ (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: { layoutOnly: true },
+ }),
+ ).toThrow(/requires `layoutOnly: true`/);
+ }
+ },
+ );
+
+ it("does not treat a block with content as an allowed layout block", () => {
+ expect(
+ validate({
+ box: { layoutOnly: true, children: { allow: ["alert"] } },
+ alert: { content: "inline" },
+ }),
+ ).toThrow(/is not a layout block/);
+ });
+
+ it("rejects `dissolve` without an allow list", () => {
+ expect(
+ validate({ box: { layoutOnly: true, children: { dissolve: false } } }),
+ ).toThrow(/`children.dissolve` needs an `allow` list/);
+ });
+
+ it.each([-1, 1.5, Number.NaN])("rejects `min: %s`", (min) => {
+ expect(
+ validate({
+ columnList: {
+ layoutOnly: true,
+ children: { allow: ["column"], min },
+ },
+ column: { layoutOnly: true, placeable: "namedOnly" },
+ }),
+ ).toThrow(/`children.min` must be a whole number that is 0 or more/);
+ });
+
+ it("rejects `dissolve` on a block that is not a layout block", () => {
+ expect(
+ validate({ alert: { content: "inline", children: { dissolve: false } } }),
+ ).toThrow(/requires `layoutOnly: true`/);
+ });
+
+ it("rejects named-only placement on a block that is not a layout block", () => {
+ expect(
+ validate({ alert: { content: "inline", placeable: "namedOnly" } }),
+ ).toThrow(/only for layout blocks/);
+ });
+
+ it("rejects a named-only block that no allow list names", () => {
+ // The editor could not insert it anywhere.
+ expect(
+ validate({ step: { layoutOnly: true, placeable: "namedOnly" } }),
+ ).toThrow(/no block names it/);
+ expect(
+ validate({
+ stepper: { layoutOnly: true, children: { allow: ["step"] } },
+ step: { layoutOnly: true, placeable: "namedOnly" },
+ }),
+ ).not.toThrow();
+ });
+
+ it.each(["typo", "blockGroupChild", "toString"])(
+ "rejects an allow entry that is not a configured block: %s",
+ (allowed) => {
+ expect(
+ validate({ box: { layoutOnly: true, children: { allow: [allowed] } } }),
+ ).toThrow(/not a configured block type/);
+ },
+ );
+
+ it("rejects a regular block type in the allow array", () => {
+ expect(
+ validate({ box: { layoutOnly: true, children: { allow: ["heading"] } } }),
+ ).toThrow(/not supported yet/);
+ });
+});
diff --git a/packages/core/src/schema/blocks/children.ts b/packages/core/src/schema/blocks/children.ts
new file mode 100644
index 0000000000..ef8bd38995
--- /dev/null
+++ b/packages/core/src/schema/blocks/children.ts
@@ -0,0 +1,136 @@
+import type { Node, NodeType, Schema } from "prosemirror-model";
+
+import type { BlockConfig, ChildrenConfig } from "./types.js";
+
+export const CHILD_CONTAINER_GROUP = "childContainer";
+
+export const BLOCK_GROUP_CHILD_GROUP = "blockGroupChild";
+
+// Naming: a "layout block" is a block declared `layoutOnly: true`, whose own
+// node holds its child blocks. Two older names with "container" mean
+// something else:
+// - `blockContainer` is the node around every other block (its content node
+// plus an optional `blockGroup` of children). Its name is kept because it
+// is stored in documents.
+// - `childContainer` (`CHILD_CONTAINER_GROUP`) is the node group of every
+// node that holds a block's children: `blockGroup` and the nodes of layout
+// blocks.
+//
+// Whether `type` is the node of a layout block: a node that holds child
+// blocks directly and 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 isLayoutNode(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 layout block declared `placeable: "namedOnly"`: one
+ * defined only in terms of the layout block 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 isLayoutNode(type) && type.spec.blockConfig?.placeable === "namedOnly";
+}
+
+// Below `blockContainer`'s priority (50) so PM's `fillBefore` picks
+// `blockContainer` first, avoiding recursion through nested layout blocks.
+export const LAYOUT_NODE_PRIORITY = 40;
+
+const LAYOUT_PRIORITY_BAND = { min: 30, max: 49 };
+const DEFAULT_SPEC_PRIORITY = 101;
+
+// Maps `sortByDependencies` priority into the layout block band (30–49).
+// Preserves relative order but keeps all layout blocks below regular blocks.
+export function layoutNodePriority(priority: number | undefined): number {
+ if (priority === undefined) {
+ return LAYOUT_NODE_PRIORITY;
+ }
+
+ const steps = Math.round((priority - DEFAULT_SPEC_PRIORITY) / 10);
+
+ return Math.min(
+ LAYOUT_PRIORITY_BAND.max,
+ Math.max(LAYOUT_PRIORITY_BAND.min, LAYOUT_NODE_PRIORITY + steps),
+ );
+}
+
+/** How few children a layout block must hold: its `min` (an `allow` list only), else 1. */
+export function minChildren(children: ChildrenConfig | undefined): number {
+ return children !== undefined && "min" in children ? (children.min ?? 1) : 1;
+}
+
+/**
+ * The content expression for "any blocks": the `BLOCK_GROUP_CHILD_GROUP` group
+ * itself, or, for a layout block that excludes some layout block types, the group's
+ * other members spelled out: `blockContainer` (the node every regular block
+ * shares) and the layout block types placeable anywhere. That takes the whole
+ * schema, which is why only a layout block with `exclude` needs it.
+ */
+export function anyBlocksExpression(
+ exclude: readonly string[],
+ blockSpecs: Record<
+ string,
+ { config: Pick }
+ >,
+): string {
+ if (exclude.length === 0) {
+ return BLOCK_GROUP_CHILD_GROUP;
+ }
+ const layoutBlocks = Object.entries(blockSpecs)
+ .filter(
+ ([type, { config }]) =>
+ config.layoutOnly === true &&
+ config.placeable !== "namedOnly" &&
+ !exclude.includes(type),
+ )
+ .map(([type]) => type);
+ const members = ["blockContainer", ...layoutBlocks];
+ return members.length === 1 ? members[0] : `(${members.join(" | ")})`;
+}
+
+/**
+ * Compiles a layout block'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`). `anyBlocks` is what `allow: "blocks"` stands for:
+ * the group, unless the layout block excludes types (see `anyBlocksExpression`).
+ */
+export function childrenContentExpression(
+ children: ChildrenConfig = {},
+ anyBlocks: string = BLOCK_GROUP_CHILD_GROUP,
+): string {
+ const { allow = "blocks" } = children;
+ const min = minChildren(children);
+
+ let allowed: string;
+ if (allow === "blocks") {
+ allowed = anyBlocks;
+ } else {
+ if (allow.length === 0) {
+ throw new Error(
+ "Layout block `allow` permits nothing. A layout block must accept at least one block or layout block 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/createSpec.browser.test.ts b/packages/core/src/schema/blocks/createSpec.browser.test.ts
new file mode 100644
index 0000000000..dec7d60445
--- /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 frameDiv = () => {
+ const dom = document.createElement("div");
+ return { dom, slot: dom };
+};
+
+// A layout block that recognizes its own external HTML. Before layout blocks
+// 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",
+ layoutOnly: true,
+ },
+ {
+ renderFrame: frameDiv,
+ 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",
+ layoutOnly: true,
+ },
+ {
+ renderFrame: frameDiv,
+ 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("layout block `parse`", () => {
+ it("parses an external element into a layout block, 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(
+ "