Skip to content
Closed
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
9 changes: 9 additions & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->
1 change: 1 addition & 0 deletions docs/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
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 that can wrap a paragraph followed by a code block, or any combination of nested blocks.

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 — anything goes.
- 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>
);
}
103 changes: 103 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,103 @@
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 new
// `children` config — the block hosts arbitrary child blocks in its body,
// exposed at runtime as `block.children`.
//
// The callout's title demonstrates the complementary "string prop slot"
// 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})`}
>
Comment on lines +70 to +77

Copy link
Copy Markdown
Contributor

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-main"}>
{/* The title lives in a string prop, not in document content —
it's 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