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

In this example, we create a custom `Callout` block that holds **other blocks** as its body — like a Notion-style callout that can wrap a paragraph followed by a code block, or any combination of nested blocks.

The block uses the new `container` config on `BlockConfig`. Setting `container: { defaultBlocks: ["paragraph"] }` (with `content: "none"`) tells BlockNote to emit a ProseMirror node that holds nested `blockContainer+` children — the same shape that columns use under the hood. The contained blocks live on `block.children` at runtime.

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 — anything goes.
- 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:**

- [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/08-container-block/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<html lang="en">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add HTML5 doctype at the top.

Line [1] starts with <html> directly. Add <!doctype html> before it to avoid quirks-mode rendering differences.

Suggested fix
+<!doctype html>
 <html lang="en">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<html lang="en">
<!doctype html>
<html lang="en">
🧰 Tools
🪛 HTMLHint (1.9.2)

[error] 1-1: Doctype must be declared before any non-comment content.

(doctype-first)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/06-custom-schema/08-container-block/index.html` at line 1, Add the
HTML5 doctype declaration before the opening <html> tag in the file (the top of
examples/06-custom-schema/08-container-block/index.html) so the document starts
with <!doctype html> to prevent quirks-mode rendering; simply insert the doctype
line immediately above the existing <html lang="en"> tag.

<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/08-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>
);
32 changes: 32 additions & 0 deletions examples/06-custom-schema/08-container-block/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"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": "^8.3.11",
"@mantine/hooks": "^8.3.11",
"@mantine/utils": "^6.0.22",
"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.8"
}
}
117 changes: 117 additions & 0 deletions examples/06-custom-schema/08-container-block/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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 `defaultBlocks` (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>
);
}
76 changes: 76 additions & 0 deletions examples/06-custom-schema/08-container-block/src/Callout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { createReactBlockSpec, NodeViewWrapper } 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 new `container`
// config — the block hosts arbitrary child blocks in its body, exposed at
// runtime as `block.children`.
export const createCallout = createReactBlockSpec(
{
type: "callout",
propSchema: {
flavor: {
default: "tip",
values: ["tip", "info", "warning", "success"],
},
},
content: "none",
container: {
min: 1,
defaultBlocks: ["paragraph"],
},
},
{
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 },
});
};

return (
<NodeViewWrapper
className={"callout"}
data-node-type="callout"
data-id={props.block.id}
data-flavor={flavor.value}
>
<button
className={"callout-icon-button"}
type={"button"}
contentEditable={false}
onClick={cycleFlavor}
title={`Click to cycle flavor (current: ${flavor.title})`}
>
Comment on lines +62 to +68
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add an explicit accessible name to the flavor toggle.

This is an icon-only button, so relying on title alone leaves its accessible name inconsistent across assistive tech.

Suggested change
           <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})`}
           >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
className={"callout-icon-button"}
type={"button"}
contentEditable={false}
onClick={cycleFlavor}
title={`Click to cycle flavor (current: ${flavor.title})`}
>
<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})`}
>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/06-custom-schema/08-container-block/src/Callout.tsx` around lines 62
- 68, The icon-only toggle button rendered in Callout.tsx (button with className
"callout-icon-button", onClick={cycleFlavor}, title={`Click to cycle flavor
(current: ${flavor.title})`}) lacks an explicit accessible name; add one by
supplying an aria-label (or aria-labelledby) that conveys its purpose and
current state (e.g., include flavor.title) so screen readers get a consistent
name across AT instead of relying solely on title attributes.

<Icon size={20} />
</button>
<div className={"callout-body"} ref={props.contentRef} />
</NodeViewWrapper>
);
},
},
);
93 changes: 93 additions & 0 deletions examples/06-custom-schema/08-container-block/src/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
.wrapper {
display: flex;
flex-direction: column;
height: 100%;
}

.item {
border-radius: 0.5rem;
flex: 1;
overflow: hidden;
}

.item.bordered {
border: 1px solid gray;
}

.item pre {
border-radius: 0.5rem;
height: 100%;
overflow: auto;
padding-block: 1rem;
padding-inline: 54px;
width: 100%;
white-space: pre-wrap;
}

.callout {
display: flex;
align-items: flex-start;
gap: 12px;
flex-grow: 1;
border-radius: 6px;
padding: 12px 16px;
border-left: 4px solid var(--callout-accent, #888);
background-color: var(--callout-bg, #f3f4f6);
}

.callout[data-flavor="tip"] {
--callout-accent: #d97706;
--callout-bg: #fff7ed;
}

.callout[data-flavor="info"] {
--callout-accent: #507aff;
--callout-bg: #e6ebff;
}

.callout[data-flavor="warning"] {
--callout-accent: #b91c1c;
--callout-bg: #fef2f2;
}

.callout[data-flavor="success"] {
--callout-accent: #16a34a;
--callout-bg: #ecfdf5;
}

[data-color-scheme="dark"] .callout[data-flavor="tip"] {
--callout-bg: #432e0e;
}

[data-color-scheme="dark"] .callout[data-flavor="info"] {
--callout-bg: #1e2a5c;
}

[data-color-scheme="dark"] .callout[data-flavor="warning"] {
--callout-bg: #4a1212;
}

[data-color-scheme="dark"] .callout[data-flavor="success"] {
--callout-bg: #0d3b21;
}

.callout-icon-button {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: var(--callout-accent, #888);
display: flex;
align-items: center;
justify-content: center;
margin-top: 2px;
}

.callout-icon-button:hover {
opacity: 0.75;
}

.callout-body {
flex-grow: 1;
min-width: 0;
}
Loading
Loading