Skip to content

Commit 9270882

Browse files
nperez0111claude
andcommitted
feat: combinator content schemas for custom blocks
Introduce ContentType abstraction and POJO combinator API (`c.inline`, `c.none`, `c.record`, `c.list`, `c.blocks`, `c.props`) for declaring arbitrary block content shapes — multi-slot records, variable-length lists, regions of editor blocks, and typed per-item props. Block JSON shapes are derived directly from the schema. Phase 1 rebuilds the table block on top of the new ContentType primitive with no observable behaviour or JSON-shape change. Phase 3 ships the combinator surface, widens createReactBlockSpec to accept ContentType, and adds 4 runnable examples (multi-slot alert, FAQ, callout, tab group) plus end-to-end tests for the React render path and the data layer. Also fixes textCursorPosition's parent walk-up to traverse multiple non-bnBlock layers, which combinator slot chains can introduce. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8508f81 commit 9270882

67 files changed

Lines changed: 4300 additions & 174 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"playground": true,
3+
"docs": true,
4+
"author": "yousefed",
5+
"tags": [
6+
"Intermediate",
7+
"Blocks",
8+
"Custom Schemas",
9+
"Combinator Content"
10+
],
11+
"dependencies": {
12+
"@mantine/core": "^8.3.11",
13+
"react-icons": "^5.5.0"
14+
}
15+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Multi-Slot Alert Block
2+
3+
In this example, we create a custom `Alert` block whose content is a
4+
**combinator content schema** — a record of two inline regions, `title` and
5+
`body`. The block JSON exposes both slots as named keys, and the editor
6+
displays the document's JSON live so you can see the resulting shape.
7+
8+
This is the same alert idea as `01-alert-block`, but with a richer content
9+
shape: where the simple alert has a single inline region, this one has two
10+
independently editable regions stored as named slots in the JSON.
11+
12+
```ts
13+
const alertContentType = combinatorContentType(
14+
"alert",
15+
c.record({
16+
title: c.inline(),
17+
body: c.inline(),
18+
}),
19+
);
20+
```
21+
22+
The block's content JSON is automatically derived from the schema:
23+
24+
```json
25+
{
26+
"type": "alert",
27+
"props": { "variant": "warning" },
28+
"content": {
29+
"title": [{ "type": "text", "text": "Heads up", "styles": {} }],
30+
"body": [{ "type": "text", "text": "Be careful.", "styles": {} }]
31+
}
32+
}
33+
```
34+
35+
**Try it out:** click the icon to change the alert variant, and edit the title
36+
and body inline. Watch the JSON panel below update in real time.
37+
38+
**Relevant Docs:**
39+
40+
- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)
41+
- [Editor Setup](/docs/getting-started/editor-setup)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<html lang="en">
2+
<head>
3+
<meta charset="UTF-8" />
4+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
5+
<title>Multi-Slot Alert Block</title>
6+
<script>
7+
<!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -->
8+
</script>
9+
</head>
10+
<body>
11+
<div id="root"></div>
12+
<script type="module" src="./main.tsx"></script>
13+
</body>
14+
</html>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
2+
import React from "react";
3+
import { createRoot } from "react-dom/client";
4+
import App from "./src/App.jsx";
5+
6+
const root = createRoot(document.getElementById("root")!);
7+
root.render(
8+
<React.StrictMode>
9+
<App />
10+
</React.StrictMode>
11+
);
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "@blocknote/example-custom-schema-multi-slot-alert-block",
3+
"description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
4+
"type": "module",
5+
"private": true,
6+
"version": "0.12.4",
7+
"scripts": {
8+
"start": "vite",
9+
"dev": "vite",
10+
"build:prod": "tsc && vite build",
11+
"preview": "vite preview"
12+
},
13+
"dependencies": {
14+
"@blocknote/ariakit": "latest",
15+
"@blocknote/core": "latest",
16+
"@blocknote/mantine": "latest",
17+
"@blocknote/react": "latest",
18+
"@blocknote/shadcn": "latest",
19+
"@mantine/core": "^8.3.11",
20+
"@mantine/hooks": "^8.3.11",
21+
"@mantine/utils": "^6.0.22",
22+
"react": "^19.2.3",
23+
"react-dom": "^19.2.3",
24+
"react-icons": "^5.5.0"
25+
},
26+
"devDependencies": {
27+
"@types/react": "^19.2.3",
28+
"@types/react-dom": "^19.2.3",
29+
"@vitejs/plugin-react": "^6.0.1",
30+
"vite": "^8.0.8"
31+
}
32+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { c, combinatorContentType } from "@blocknote/core";
2+
import { createReactBlockSpec } from "@blocknote/react";
3+
import { Menu } from "@mantine/core";
4+
import { MdCancel, MdCheckCircle, MdError, MdInfo } from "react-icons/md";
5+
6+
import "./styles.css";
7+
8+
// The variants of alert that users can choose from.
9+
export const alertVariants = [
10+
{ value: "warning", title: "Warning", icon: MdError },
11+
{ value: "error", title: "Error", icon: MdCancel },
12+
{ value: "info", title: "Info", icon: MdInfo },
13+
{ value: "success", title: "Success", icon: MdCheckCircle },
14+
] as const;
15+
16+
// The content schema: a record of two named inline regions. The block's
17+
// `content` JSON shape is automatically derived as
18+
// `{ title: InlineContent[]; body: InlineContent[] }`.
19+
const alertContentType = combinatorContentType(
20+
"alert",
21+
c.record({
22+
title: c.inline(),
23+
body: c.inline(),
24+
}),
25+
);
26+
27+
export const createAlert = createReactBlockSpec(
28+
{
29+
type: "alert",
30+
propSchema: {
31+
variant: {
32+
default: "warning",
33+
values: ["warning", "error", "info", "success"] as const,
34+
},
35+
},
36+
content: alertContentType,
37+
},
38+
{
39+
render: (props) => {
40+
const variant =
41+
alertVariants.find((v) => v.value === props.block.props.variant) ??
42+
alertVariants[0];
43+
const Icon = variant.icon;
44+
45+
return (
46+
<div
47+
className="alert"
48+
data-alert-variant={props.block.props.variant}>
49+
{/* Icon — non-editable; opens a menu to change the variant. */}
50+
<Menu withinPortal={false}>
51+
<Menu.Target>
52+
<div className="alert-icon-wrapper" contentEditable={false}>
53+
<Icon
54+
className="alert-icon"
55+
data-alert-icon-variant={props.block.props.variant}
56+
size={28}
57+
/>
58+
</div>
59+
</Menu.Target>
60+
<Menu.Dropdown>
61+
<Menu.Label>Alert variant</Menu.Label>
62+
<Menu.Divider />
63+
{alertVariants.map((v) => {
64+
const ItemIcon = v.icon;
65+
return (
66+
<Menu.Item
67+
key={v.value}
68+
leftSection={
69+
<ItemIcon
70+
className="alert-icon"
71+
data-alert-icon-variant={v.value}
72+
/>
73+
}
74+
onClick={() =>
75+
props.editor.updateBlock(props.block, {
76+
type: "alert",
77+
props: { variant: v.value },
78+
})
79+
}>
80+
{v.title}
81+
</Menu.Item>
82+
);
83+
})}
84+
</Menu.Dropdown>
85+
</Menu>
86+
{/*
87+
Content slots: the parent record's children (title + body) mount
88+
as siblings inside this element. Each slot is a real ProseMirror
89+
node, identified by `data-content-name="alert__<field>"`, which we
90+
target with CSS to give title and body distinct styling.
91+
*/}
92+
<div className="alert-slots" ref={props.contentRef} />
93+
</div>
94+
);
95+
},
96+
},
97+
);
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core";
2+
import "@blocknote/core/fonts/inter.css";
3+
import { BlockNoteView } from "@blocknote/mantine";
4+
import "@blocknote/mantine/style.css";
5+
import { useCreateBlockNote } from "@blocknote/react";
6+
import { useEffect, useState } from "react";
7+
8+
import { createAlert } from "./Alert.js";
9+
import "./styles.css";
10+
11+
// Schema with the multi-slot alert block added.
12+
const schema = BlockNoteSchema.create({
13+
blockSpecs: {
14+
...defaultBlockSpecs,
15+
alert: createAlert(),
16+
},
17+
});
18+
19+
export default function App() {
20+
// The editor's `document` carries the full custom-schema type (including
21+
// the `alert` block with its `{ title, body }` content), so we infer the
22+
// state type from it instead of using the unparameterized `Block`.
23+
const [blocks, setBlocks] = useState<typeof editor.document>([]);
24+
25+
// Editor preloaded with an example alert that has both slots populated, so
26+
// the JSON panel below shows the `{ title, body }` shape from the start.
27+
const editor = useCreateBlockNote({
28+
schema,
29+
initialContent: [
30+
{
31+
type: "paragraph",
32+
content: "An alert below has two independent rich-text regions:",
33+
},
34+
{
35+
type: "alert" as const,
36+
props: { variant: "info" },
37+
content: {
38+
title: [
39+
{ type: "text", text: "Heads up", styles: { bold: true } },
40+
],
41+
body: [
42+
{ type: "text", text: "Title and body are ", styles: {} },
43+
{ type: "text", text: "separate slots", styles: { italic: true } },
44+
{ type: "text", text: " in the JSON.", styles: {} },
45+
],
46+
} as any,
47+
} as any,
48+
{
49+
type: "paragraph",
50+
content:
51+
"Edit either slot and watch the JSON below — title and body update independently.",
52+
},
53+
],
54+
});
55+
56+
useEffect(() => setBlocks(editor.document), [editor]);
57+
58+
return (
59+
<div className="wrapper">
60+
<div>BlockNote Editor:</div>
61+
<div className="item">
62+
<BlockNoteView
63+
editor={editor}
64+
onChange={() => setBlocks(editor.document)}
65+
/>
66+
</div>
67+
<div>Document JSON:</div>
68+
<div className="item bordered">
69+
<pre>
70+
<code>{JSON.stringify(blocks, null, 2)}</code>
71+
</pre>
72+
</div>
73+
</div>
74+
);
75+
}

0 commit comments

Comments
 (0)