Skip to content

Commit 0b130ea

Browse files
nperez0111claude
andcommitted
feat(examples): add macro block example
Adds a new custom-schema example built with the vanilla createBlockSpec API. Each macro block stores an `id` prop that is used to look up arbitrary content in a global registry; that content is injected as the "before" and "after" decoration around the block's editable inline content. Each registry slot accepts either an HTML string (injected via innerHTML) or a live HTMLElement (injected via appendChild). The demo ships two entries: a static "warning" entry with HTML-string before/after, and a "note" entry whose "before" slot is a real <input> element — showing that interactive controls can sit alongside the editable content without being part of the BlockNote document JSON. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5979e7f commit 0b130ea

12 files changed

Lines changed: 427 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"playground": true,
3+
"docs": true,
4+
"author": "matthewlipski",
5+
"tags": [
6+
"Intermediate",
7+
"Blocks",
8+
"Custom Schemas"
9+
],
10+
"dependencies": {}
11+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Macro Block
2+
3+
In this example, we create a custom `Macro` block using the vanilla `createBlockSpec` API from `@blocknote/core`. Each macro block stores an `id` prop, and the `id` is used to look up "before" and "after" HTML strings in a global map. Those strings are injected as html on either side of the editable inline content.
4+
5+
This pattern is useful when you want a block whose decoration is driven by a runtime registry — for example, server-driven labels, citations, or templated wrappers.
6+
7+
**Try it out:** Edit the inline text inside each macro block. Notice that the "before" and "after" decorations stay non-editable, and that swapping the `id` in `App.tsx` would change the surrounding HTML without touching the block's content.
8+
9+
**Relevant Docs:**
10+
11+
- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)
12+
- [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>Macro 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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"name": "@blocknote/example-custom-schema-macro-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": "^9.0.2",
20+
"@mantine/hooks": "^9.0.2",
21+
"react": "^19.2.3",
22+
"react-dom": "^19.2.3"
23+
},
24+
"devDependencies": {
25+
"@types/react": "^19.2.3",
26+
"@types/react-dom": "^19.2.3",
27+
"@vitejs/plugin-react": "^6.0.1",
28+
"vite": "^8.0.8"
29+
}
30+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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+
7+
import { macroBlock } from "./Macro";
8+
import "./styles.css";
9+
10+
const schema = BlockNoteSchema.create({
11+
blockSpecs: {
12+
...defaultBlockSpecs,
13+
macro: macroBlock(),
14+
},
15+
});
16+
17+
export default function App() {
18+
const editor = useCreateBlockNote({
19+
schema,
20+
initialContent: [
21+
{
22+
type: "paragraph",
23+
content:
24+
"Below are two macro blocks. Each looks up its before/after content from a global registry by id — the first uses HTML strings, the second uses a live <input> element:",
25+
},
26+
{
27+
type: "macro",
28+
props: { id: "warning" },
29+
content: "Stay hydrated while editing",
30+
},
31+
{
32+
type: "macro",
33+
props: { id: "note" },
34+
content: "Type a note here, then add a label on the left",
35+
},
36+
{
37+
type: "paragraph",
38+
content: "Edit the inline text inside each macro to see it stay live.",
39+
},
40+
],
41+
});
42+
43+
return <BlockNoteView editor={editor} />;
44+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { createBlockSpec, defaultProps } from "@blocknote/core";
2+
3+
import { macroRegistry } from "./macroRegistry.js";
4+
5+
// The Macro block — built with the vanilla `createBlockSpec` API.
6+
//
7+
// It carries a single `id` prop that is used to look up the HTML that should
8+
// be rendered in the "before" and "after" slots around the block's editable
9+
// inline content. This can actually be in any structure, it is in your control
10+
export const macroBlock = createBlockSpec(
11+
{
12+
type: "macro",
13+
propSchema: {
14+
textAlignment: defaultProps.textAlignment,
15+
textColor: defaultProps.textColor,
16+
id: {
17+
default: "" as const,
18+
},
19+
},
20+
content: "inline",
21+
},
22+
{
23+
render: (block) => {
24+
const wrapper = document.createElement("div");
25+
wrapper.className = "macro-block";
26+
27+
// We pull from the "registry" what this block should insert before and after
28+
const definition = macroRegistry[block.props.id];
29+
30+
const before = document.createElement("div");
31+
before.className = "macro-slot macro-slot-before";
32+
before.contentEditable = "false";
33+
const beforeContent = definition?.before ?? "";
34+
if (typeof beforeContent === "string") {
35+
before.innerHTML = beforeContent;
36+
} else {
37+
before.appendChild(beforeContent);
38+
}
39+
40+
const content = document.createElement("div");
41+
content.className = "macro-content";
42+
43+
const after = document.createElement("div");
44+
after.className = "macro-slot macro-slot-after";
45+
after.contentEditable = "false";
46+
const afterContent = definition?.after ?? "";
47+
if (typeof afterContent === "string") {
48+
after.innerHTML = afterContent;
49+
} else {
50+
after.appendChild(afterContent);
51+
}
52+
53+
// You have full control over this HTML structure
54+
// and can inject the rich-text content wherever you want
55+
wrapper.append(before, content, after);
56+
57+
return {
58+
dom: wrapper,
59+
contentDOM: content,
60+
};
61+
},
62+
},
63+
);
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// A global, runtime-mutable registry that maps a macro id to the content that
2+
// should be injected before and after the block's editable inline content.
3+
//
4+
// Each slot can be either:
5+
// - an HTML string → injected via innerHTML
6+
// - an HTMLElement → injected via appendChild (lets you put live, stateful
7+
// DOM into the slot — e.g. an <input>)
8+
//
9+
// In a real application this could be populated from a server response, a
10+
// shared module, or any other side channel — the block render function only
11+
// reads from it.
12+
export type MacroDefinition = {
13+
before: string | HTMLElement;
14+
after: string | HTMLElement;
15+
};
16+
17+
// An interactive "before" slot: a real <input> that lives in the registry and
18+
// gets appended into whichever macro block uses this id. This mirrors the
19+
// pattern from the Alert block's title input — but here it's authored as
20+
// vanilla DOM and managed by the registry rather than by React state.
21+
const labelInput = document.createElement("input");
22+
labelInput.className = "macro-label-input";
23+
labelInput.type = "text";
24+
labelInput.placeholder = "Label…";
25+
labelInput.setAttribute("aria-label", "Macro label");
26+
27+
export const macroRegistry: Record<string, MacroDefinition> = {
28+
warning: {
29+
before: `
30+
<span class="macro-badge macro-badge-warning">
31+
<span class="macro-emoji">⚠️</span>
32+
Heads up
33+
</span>
34+
`,
35+
after: `
36+
<a class="macro-link" href="https://www.blocknotejs.org/" target="_blank" rel="noreferrer">
37+
Read the docs →
38+
</a>
39+
`,
40+
},
41+
note: {
42+
// An HTMLElement instead of a string — the macro block will appendChild it.
43+
before: labelInput,
44+
after: `
45+
<span class="macro-meta">— the input on the left is a live DOM node</span>
46+
`,
47+
},
48+
};
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
.macro-block {
2+
align-items: stretch;
3+
background: rgba(0, 0, 0, 0.03);
4+
border: 1px solid rgba(0, 0, 0, 0.06);
5+
border-radius: 6px;
6+
display: flex;
7+
flex-direction: column;
8+
flex-grow: 1;
9+
gap: 6px;
10+
padding: 8px 12px;
11+
}
12+
13+
[data-color-scheme="dark"] .macro-block {
14+
background: rgba(255, 255, 255, 0.04);
15+
border-color: rgba(255, 255, 255, 0.08);
16+
}
17+
18+
.macro-content {
19+
flex-grow: 1;
20+
min-width: 0;
21+
}
22+
23+
.macro-slot {
24+
align-items: center;
25+
align-self: flex-start;
26+
display: inline-flex;
27+
user-select: none;
28+
}
29+
30+
.macro-badge {
31+
align-items: center;
32+
border-radius: 999px;
33+
display: inline-flex;
34+
font-size: 0.75em;
35+
font-weight: 600;
36+
gap: 4px;
37+
padding: 2px 10px;
38+
}
39+
40+
.macro-badge-greeting {
41+
background: #e0e7ff;
42+
color: #312e81;
43+
}
44+
45+
.macro-badge-warning {
46+
background: #fef3c7;
47+
color: #78350f;
48+
}
49+
50+
.macro-emoji {
51+
font-size: 1em;
52+
}
53+
54+
.macro-meta {
55+
color: rgba(0, 0, 0, 0.45);
56+
font-size: 0.8em;
57+
font-style: italic;
58+
}
59+
60+
[data-color-scheme="dark"] .macro-meta {
61+
color: rgba(255, 255, 255, 0.5);
62+
}
63+
64+
.macro-link {
65+
color: #2563eb;
66+
font-size: 0.8em;
67+
font-weight: 500;
68+
text-decoration: none;
69+
}
70+
71+
.macro-link:hover {
72+
text-decoration: underline;
73+
}
74+
75+
.macro-label-input {
76+
background: transparent;
77+
border: none;
78+
border-radius: 3px;
79+
color: inherit;
80+
cursor: text;
81+
font-family: inherit;
82+
font-size: 0.8em;
83+
font-weight: 600;
84+
outline: none;
85+
padding: 2px 6px;
86+
width: 140px;
87+
}
88+
89+
.macro-label-input::placeholder {
90+
color: rgba(0, 0, 0, 0.35);
91+
font-weight: 500;
92+
}
93+
94+
.macro-label-input:hover:not(:focus) {
95+
background-color: rgba(0, 0, 0, 0.04);
96+
}
97+
98+
[data-color-scheme="dark"] .macro-label-input::placeholder {
99+
color: rgba(255, 255, 255, 0.4);
100+
}
101+
102+
[data-color-scheme="dark"] .macro-label-input:hover:not(:focus) {
103+
background-color: rgba(255, 255, 255, 0.06);
104+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
3+
"compilerOptions": {
4+
"target": "ESNext",
5+
"useDefineForClassFields": true,
6+
"lib": [
7+
"DOM",
8+
"DOM.Iterable",
9+
"ESNext"
10+
],
11+
"allowJs": false,
12+
"skipLibCheck": true,
13+
"esModuleInterop": false,
14+
"allowSyntheticDefaultImports": true,
15+
"strict": true,
16+
"forceConsistentCasingInFileNames": true,
17+
"module": "ESNext",
18+
"moduleResolution": "bundler",
19+
"resolveJsonModule": true,
20+
"isolatedModules": true,
21+
"noEmit": true,
22+
"jsx": "react-jsx",
23+
"composite": true
24+
},
25+
"include": [
26+
"."
27+
],
28+
"__ADD_FOR_LOCAL_DEV_references": [
29+
{
30+
"path": "../../../packages/core/"
31+
},
32+
{
33+
"path": "../../../packages/react/"
34+
}
35+
]
36+
}

0 commit comments

Comments
 (0)