Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
330 changes: 330 additions & 0 deletions docs/content/docs/features/custom-schemas/container-blocks.mdx

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions docs/content/docs/features/custom-schemas/custom-blocks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ type BlockConfig = {
alert, so we set `content` to `"inline"`._
</Callout>

<Callout type="info">
_Any block can also hold other blocks as its body by declaring the
`children` option, with or without inline content of its own. See [Container
Blocks](/docs/features/custom-schemas/container-blocks)._
</Callout>

`propSchema:` The `PropSchema` specifies the props that the block supports. Block props (properties) are data stored with your Block in the document, and can be used to customize its appearance or behavior.

```typescript
Expand Down
11 changes: 9 additions & 2 deletions docs/content/docs/reference/editor/manipulating-content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,11 @@ editor.forEachBlock((block) => {
insertBlocks(
blocksToInsert: PartialBlock[],
referenceBlock: BlockIdentifier,
placement: "before" | "after" = "before"
placement: "before" | "after" | "start" | "end" = "before"
): void
```

Inserts new blocks relative to an existing block.
Inserts new blocks relative to an existing block. `"before"` and `"after"` make the new blocks siblings of the reference block; `"start"` and `"end"` nest them inside it, as its first or last children. See [Inserting into a container](/docs/features/custom-schemas/container-blocks#inserting-into-a-container).

```typescript
// Insert a paragraph before an existing block
Expand All @@ -164,6 +164,13 @@ editor.insertBlocks(
"existing-block-id",
"after",
);

// Insert a paragraph as the last child of a container block
editor.insertBlocks(
[{ type: "paragraph", content: "Nested paragraph" }],
"container-block-id",
"end",
);
```

### Updating Blocks
Expand Down
15 changes: 15 additions & 0 deletions examples/06-custom-schema/09-container-block/.bnexample.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
22 changes: 22 additions & 0 deletions examples/06-custom-schema/09-container-block/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Container Block

In this example, we create a custom `Callout` block that holds other blocks as its body, like a Notion-style callout wrapping a paragraph followed by a code block.

The block declares the `children` config on `BlockConfig`. `children: { min: 1, default: [{ type: "paragraph" }] }` makes it a container: its child blocks mount into the element the render passes `contentRef` to, and live on `block.children` at runtime.

The callout's **title** demonstrates the complementary "string prop slot" pattern: a field that doesn't need rich text, comments, or multiplayer cursors can live in a plain string prop, edited through a regular `<input>` rendered inside the block (in a `contentEditable={false}` wrapper) and committed via `editor.updateBlock`. A field that _is_ prose belongs in the block's own `content: "inline"` instead.

We also wire up a Slash Menu item to insert the callout, and render the document JSON next to the editor so you can inspect the structure of the nested blocks.

**Try it out:**

- Press the "/" key inside the callout's body and add a code block, heading, or list.
- Type a title into the title field. It's stored on `block.props.title`, not as document content.
- Watch the JSON panel on the right update as you edit; the callout's children appear in `block.children`.
- Insert a new callout via the Slash Menu (search "callout").

**Relevant Docs:**

- [Container Blocks](/docs/features/custom-schemas/container-blocks)
- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)
- [Editor Setup](/docs/getting-started/editor-setup)
14 changes: 14 additions & 0 deletions examples/06-custom-schema/09-container-block/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Container Block</title>
<script>
<!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -->
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
11 changes: 11 additions & 0 deletions examples/06-custom-schema/09-container-block/main.tsx
Original file line number Diff line number Diff line change
@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
);
31 changes: 31 additions & 0 deletions examples/06-custom-schema/09-container-block/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@blocknote/example-custom-schema-container-block",
"description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
"type": "module",
"private": true,
"version": "0.12.4",
"scripts": {
"start": "vite",
"dev": "vite",
"build:prod": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@blocknote/ariakit": "latest",
"@blocknote/core": "latest",
"@blocknote/mantine": "latest",
"@blocknote/react": "latest",
"@blocknote/shadcn": "latest",
"@mantine/core": "^9.0.2",
"@mantine/hooks": "^9.0.2",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-icons": "^5.5.0"
},
"devDependencies": {
"@types/react": "^19.2.3",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.0"
}
}
118 changes: 118 additions & 0 deletions examples/06-custom-schema/09-container-block/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core";
import {
filterSuggestionItems,
insertOrUpdateBlockForSlashMenu,
} from "@blocknote/core/extensions";
import "@blocknote/core/fonts/inter.css";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import {
SuggestionMenuController,
getDefaultReactSlashMenuItems,
useCreateBlockNote,
} from "@blocknote/react";
import { useEffect, useState } from "react";
import { RiChatQuoteLine } from "react-icons/ri";

import { createCallout } from "./Callout";
import "./styles.css";

// Schema with the default blocks plus our custom Callout container block.
const schema = BlockNoteSchema.create().extend({
blockSpecs: {
...defaultBlockSpecs,
callout: createCallout(),
},
});

// Slash menu item to insert a Callout. Because Callout is a container block,
// inserting one with no children causes BlockNote to seed it with the block's
// configured `children.default` (a single paragraph here).
const insertCallout = (editor: typeof schema.BlockNoteEditor) => ({
title: "Callout",
subtext: "Container block that wraps other blocks",
onItemClick: () =>
insertOrUpdateBlockForSlashMenu(editor, {
type: "callout",
}),
aliases: ["callout", "container", "alert", "note", "tip", "info"],
group: "Basic blocks",
icon: <RiChatQuoteLine />,
});

type AppBlock = (typeof schema.BlockNoteEditor)["document"][number];

export default function App() {
const [blocks, setBlocks] = useState<AppBlock[]>([]);

const editor = useCreateBlockNote({
schema,
initialContent: [
{
type: "paragraph",
content: "Welcome! This demo shows the new container block kind.",
},
{
type: "callout",
props: { flavor: "tip" },
children: [
{
type: "paragraph",
content: "Callouts can hold any block as their body.",
},
{
type: "paragraph",
content:
"Try pressing '/' inside this callout to add a heading or code block.",
},
],
},
{
type: "paragraph",
content: "Press '/' anywhere to insert a new Callout.",
},
{
type: "paragraph",
},
],
});

useEffect(() => setBlocks(editor.document), [editor]);

return (
<div className={"wrapper"}>
<div>BlockNote Editor:</div>
<div className={"item"}>
<BlockNoteView
editor={editor}
slashMenu={false}
onChange={() => {
setBlocks(editor.document);
}}
>
<SuggestionMenuController
triggerCharacter={"/"}
getItems={async (query) => {
const defaultItems = getDefaultReactSlashMenuItems(editor);
const lastBasicBlockIndex = defaultItems.findLastIndex(
(item) => item.group === "Basic blocks",
);
defaultItems.splice(
lastBasicBlockIndex + 1,
0,
insertCallout(editor),
);
return filterSuggestionItems(defaultItems, query);
}}
/>
</BlockNoteView>
</div>
<div>Document JSON:</div>
<div className={"item bordered"}>
<pre>
<code>{JSON.stringify(blocks, null, 2)}</code>
</pre>
</div>
</div>
);
}
104 changes: 104 additions & 0 deletions examples/06-custom-schema/09-container-block/src/Callout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { createReactBlockSpec } from "@blocknote/react";
import { MdCheckCircle, MdInfo, MdLightbulb, MdWarning } from "react-icons/md";

import "./styles.css";

// The flavors of callout the user can switch between.
export const calloutTypes = [
{ value: "tip", title: "Tip", icon: MdLightbulb },
{ value: "info", title: "Info", icon: MdInfo },
{ value: "warning", title: "Warning", icon: MdWarning },
{ value: "success", title: "Success", icon: MdCheckCircle },
] as const;

// The Callout block. Declared with `content: "none"` plus the `children`
// config: the block hosts arbitrary child blocks in its body, exposed at
// runtime as `block.children`.
//
// The callout's title shows a related pattern: content that shouldn't be
// part of the rich-text document (no formatting, comments, or multiplayer
// cursors needed) can live in a plain string prop, edited through a regular
// <input> rendered inside the block.
export const createCallout = createReactBlockSpec(
{
type: "callout",
propSchema: {
flavor: {
default: "tip",
values: ["tip", "info", "warning", "success"],
},
title: {
default: "",
},
},
content: "none",
// `children: { allow: "any" }` is the entire container declaration: any
// block is allowed, at least one is required, and BlockNote fills the
// callout with an empty paragraph when it's created. `min` / `max` /
// `default` / `whenEmptied` / `boundary` tune this.
children: { allow: "any" },
},
{
render: (props) => {
const flavor =
calloutTypes.find((c) => c.value === props.block.props.flavor) ??
calloutTypes[0];
const Icon = flavor.icon;

const cycleFlavor = () => {
const idx = calloutTypes.findIndex(
(c) => c.value === props.block.props.flavor,
);
const next = calloutTypes[(idx + 1) % calloutTypes.length];
props.editor.updateBlock(props.block, {
type: "callout",
props: { flavor: next.value },
});
};

const commitTitle = (title: string) => {
if (title !== props.block.props.title) {
props.editor.updateBlock(props.block, {
type: "callout",
props: { title },
});
}
};

return (
<div className={"callout"}>
<button
className={"callout-icon-button"}
type={"button"}
contentEditable={false}
onClick={cycleFlavor}
aria-label={`Cycle callout flavor (current: ${flavor.title})`}
title={`Click to cycle flavor (current: ${flavor.title})`}
>
<Icon size={20} />
</button>
<div className={"callout-main"}>
{/* The title lives in a string prop, not in document content,
and is edited via a plain input. `contentEditable={false}`
keeps ProseMirror from treating typing here as document
input. */}
<div className={"callout-title-wrapper"} contentEditable={false}>
<input
className={"callout-title-input"}
placeholder={"Add title"}
defaultValue={props.block.props.title}
onBlur={(event) => commitTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
</div>
<div className={"callout-body"} ref={props.contentRef} />
</div>
</div>
);
},
},
);
Loading
Loading