Skip to content

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

What We're Building

A callout block that:

  • Renders as a styled container with a colored border and content area
  • Has a type attribute: "info" | "warning" | "success"
  • Supports rich text content inside (paragraphs, lists, etc.)
  • Serializes to HTML with data-* attributes for round-trip parsing
  • Registers via EdituPluginManager so it's discoverable and lifecycle-managed

Step 1: Define the Tiptap Node

Create a file CalloutNode.ts:

typescript
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

ChoiceWhy
group: "block"Callout sits between paragraphs, not inline
content: "block+"Allows rich content (paragraphs, lists) inside — not an atom
defining: truePressing Enter at the end creates a new paragraph inside, not outside
data-callout selectorUnique tag match — won't accidentally parse other <div>s
mergeAttributesCombines 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:

css
.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:

typescript
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

tsx
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:

typescript
// 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:

typescript
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
EditingCursor enters the blockSelected as a unit, edit via updateAttributes
Insert commandwrapIn / toggleWrapinsertContent

Plugin Lifecycle Hooks

For blocks that need runtime behavior beyond rendering, use lifecycle hooks:

typescript
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:

html
<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:

  • [ ] name is kebab-case, version is valid semver
  • [ ] parseHTML uses a unique selector (e.g. [data-my-block]) to avoid false matches
  • [ ] renderHTML outputs the same data-* attributes that parseHTML reads (round-trip)
  • [ ] Commands are typed via declare module "@tiptap/core" for TypeScript autocomplete
  • [ ] Styles use the plugin styles field 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

Released under the MIT License.