-
-
Notifications
You must be signed in to change notification settings - Fork 725
feat: nested blocks POC #2697
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: nested blocks POC #2697
Changes from all commits
319e9e8
fe10ae9
1651f5b
88ec903
5a40d01
ab86f5b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
| } | ||
| } |
| 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) |
| 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> | ||
| 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> | ||
| ); |
| 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" | ||
| } | ||
| } |
| 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> | ||
| ); | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add an explicit accessible name to the flavor toggle. This is an icon-only button, so relying on 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
| <Icon size={20} /> | ||||||||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||||||||
| <div className={"callout-body"} ref={props.contentRef} /> | ||||||||||||||||||||||||||||||||
| </NodeViewWrapper> | ||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||
| 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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
🧰 Tools
🪛 HTMLHint (1.9.2)
[error] 1-1: Doctype must be declared before any non-comment content.
(doctype-first)
🤖 Prompt for AI Agents