Custom Blocks
This tutorial walks you through building a custom block from scratch and registering it via the Editu plugin system. By the end, you'll have a working Callout block with a configurable type (info, warning, success) and editable content.
Prerequisites
- A working Editu + React setup (see Getting Started)
- Familiarity with Tiptap extensions (Node.create API)
- Basic understanding of the Plugin System
What We're Building
A callout block that:
- Renders as a styled container with a colored border and content area
- Has a
typeattribute:"info"|"warning"|"success" - Supports rich text content inside (paragraphs, lists, etc.)
- Serializes to HTML with
data-*attributes for round-trip parsing - Registers via
EdituPluginManagerso it's discoverable and lifecycle-managed
Step 1: Define the Tiptap Node
Create a file CalloutNode.ts:
import { Node, mergeAttributes } from "@tiptap/core";
export type CalloutType = "info" | "warning" | "success";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
callout: {
setCallout: (attrs?: { type?: CalloutType }) => ReturnType;
toggleCallout: (attrs?: { type?: CalloutType }) => ReturnType;
updateCalloutType: (type: CalloutType) => ReturnType;
unsetCallout: () => ReturnType;
};
}
}
export const CalloutNode = Node.create({
name: "callout",
group: "block",
content: "block+",
defining: true,
addAttributes() {
return {
type: {
default: "info" as CalloutType,
parseHTML: (element) => element.getAttribute("data-type") ?? "info",
renderHTML: (attributes) => ({ "data-type": attributes.type }),
},
};
},
parseHTML() {
return [{ tag: "div[data-callout]" }];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes({ "data-callout": "", class: "callout" }, HTMLAttributes),
0,
];
},
addCommands() {
return {
setCallout:
(attrs) =>
({ commands }) =>
commands.wrapIn(this.name, attrs),
toggleCallout:
(attrs) =>
({ commands }) =>
commands.toggleWrap(this.name, attrs),
updateCalloutType:
(type) =>
({ commands }) =>
commands.updateAttributes(this.name, { type }),
unsetCallout:
() =>
({ commands }) =>
commands.lift(this.name),
};
},
});Key decisions
| Choice | Why |
|---|---|
group: "block" | Callout sits between paragraphs, not inline |
content: "block+" | Allows rich content (paragraphs, lists) inside — not an atom |
defining: true | Pressing Enter at the end creates a new paragraph inside, not outside |
data-callout selector | Unique tag match — won't accidentally parse other <div>s |
mergeAttributes | Combines our static attrs with Tiptap's global HTMLAttributes |
Atom vs Container
If your block has no editable content (like a spacer or button), use atom: true instead of content. See the QuoteCard example below.
Step 2: Add Styles
You can add styles via the plugin's styles field (auto-injected and auto-cleaned) or in your own CSS. Here's a minimal approach using the plugin:
.callout {
border-left: 4px solid;
border-radius: 0.375rem;
padding: 1rem;
margin: 1rem 0;
}
.callout[data-type="info"] {
border-color: #3b82f6;
background: #eff6ff;
}
.callout[data-type="warning"] {
border-color: #f59e0b;
background: #fffbeb;
}
.callout[data-type="success"] {
border-color: #10b981;
background: #ecfdf5;
}Step 3: Create the Plugin
Create callout-plugin.ts:
import type { EdituPlugin } from "@editu/core";
import { CalloutNode } from "./CalloutNode";
const CALLOUT_STYLES = `
.callout {
border-left: 4px solid;
border-radius: 0.375rem;
padding: 1rem;
margin: 1rem 0;
}
.callout[data-type="info"] {
border-color: #3b82f6;
background: #eff6ff;
}
.callout[data-type="warning"] {
border-color: #f59e0b;
background: #fffbeb;
}
.callout[data-type="success"] {
border-color: #10b981;
background: #ecfdf5;
}
`;
export const calloutPlugin: EdituPlugin = {
name: "editu-callout",
version: "1.0.0",
description: "Callout block with info/warning/success variants",
extensions: [CalloutNode],
styles: CALLOUT_STYLES,
};That's it — the plugin bundles the extension and its styles into a single registrable unit.
Step 4: Register and Use
import { EdituPluginManager } from "@editu/core";
import { useEdituEditor, EdituProvider, EdituEditor } from "@editu/react";
import { useEffect, useMemo } from "react";
import { calloutPlugin } from "./callout-plugin";
function Editor() {
const plugins = useMemo(() => {
const manager = new EdituPluginManager();
manager.register(calloutPlugin);
return manager;
}, []);
const editor = useEdituEditor({
extensions: plugins.getExtensions(),
});
useEffect(() => {
if (editor) plugins.setEditor(editor);
return () => plugins.destroy();
}, [editor, plugins]);
return (
<EdituProvider editor={editor}>
<EdituEditor />
</EdituProvider>
);
}Now you can use the callout commands:
// Wrap the current selection in a callout
editor.commands.setCallout({ type: "warning" });
// Toggle callout on/off
editor.commands.toggleCallout({ type: "info" });
// Change the type of the current callout
editor.commands.updateCalloutType("success");
// Unwrap — lift content out of the callout
editor.commands.unsetCallout();Atom Block Example
Not every block needs editable content. For simple data-driven blocks (cards, embeds, badges), use atom: true. Here's a condensed example from the Editu demo — a QuoteCard that stores its data in attributes:
import { Node } from "@tiptap/core";
import type { EdituPlugin } from "@editu/core";
const QuoteCardNode = Node.create({
name: "quoteCard",
group: "block",
atom: true,
selectable: true,
addAttributes() {
return {
quote: {
default: "Stay hungry, stay foolish.",
parseHTML: (el) => el.getAttribute("data-quote"),
renderHTML: (attrs) => ({ "data-quote": attrs.quote }),
},
author: {
default: "Steve Jobs",
parseHTML: (el) => el.getAttribute("data-author"),
renderHTML: (attrs) => ({ "data-author": attrs.author }),
},
};
},
parseHTML() {
return [{ tag: "div[data-quote-card]" }];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
{ "data-quote-card": "", ...HTMLAttributes },
["blockquote", {}, String(HTMLAttributes["data-quote"])],
["cite", {}, `— ${String(HTMLAttributes["data-author"])}`],
];
},
addCommands() {
return {
setQuoteCard:
(attrs) =>
({ commands }) =>
commands.insertContent({ type: "quoteCard", attrs }),
};
},
});
export const quoteCardPlugin: EdituPlugin = {
name: "quote-card",
version: "1.0.0",
extensions: [QuoteCardNode],
};Key differences from the callout:
| Callout (container) | QuoteCard (atom) | |
|---|---|---|
content | "block+" | (none) |
atom | (false) | true |
| Editing | Cursor enters the block | Selected as a unit, edit via updateAttributes |
| Insert command | wrapIn / toggleWrap | insertContent |
Plugin Lifecycle Hooks
For blocks that need runtime behavior beyond rendering, use lifecycle hooks:
const analyticsPlugin: EdituPlugin = {
name: "callout-analytics",
version: "1.0.0",
dependencies: ["editu-callout"],
onInstall(editor) {
// Runs when setEditor() connects the editor
console.log("Analytics tracking active");
},
onTransaction({ editor }) {
// Runs on every editor transaction — keep it lightweight
const callouts = editor.getJSON().content?.filter(
(node) => node.type === "callout"
);
// Report count, track changes, etc.
},
onUninstall(editor) {
// Cleanup when plugin is removed
},
};WARNING
onTransaction fires on every keystroke. Avoid heavy computation — debounce or batch if needed.
HTML Round-Trip
Custom blocks serialize to HTML with data-* attributes, since there's no Markdown equivalent:
<div data-callout data-type="warning">
<p>This is a warning callout with <strong>rich content</strong> inside.</p>
</div>When loading content, Tiptap's parseHTML matches div[data-callout] and recreates the node with its attributes. This gives you lossless round-trip between editor state and HTML storage.
Checklist
Before shipping your custom block plugin:
- [ ]
nameis kebab-case,versionis valid semver - [ ]
parseHTMLuses a unique selector (e.g.[data-my-block]) to avoid false matches - [ ]
renderHTMLoutputs the samedata-*attributes thatparseHTMLreads (round-trip) - [ ] Commands are typed via
declare module "@tiptap/core"for TypeScript autocomplete - [ ] Styles use the plugin
stylesfield for automatic injection/cleanup - [ ] If your block depends on another plugin, declare it in
dependencies
Next Steps
- Plugin System — Full API reference for
EdituPluginManager - Layout Blocks — Built-in layout primitives that follow the same pattern
- Theming — Style your blocks with Editu's CSS variable system