Skip to content

Extending

Custom extensions and toolbar buttons are authored as plain <script> against the already-loaded editor — no bundler of your own. The glue re-exports the TipTap building blocks it contains on DjangoTipTap.tiptap (Editor, Extension, Mark, Node, mergeAttributes).

Custom extensions

DjangoTipTap.registerExtension("callout", (config, ctx) => {
  const { Node, mergeAttributes } = ctx.tiptap;
  return Node.create({
    name: "callout",
    group: "block",
    content: "block+",
    parseHTML: () => [{ tag: "aside.callout" }],
    renderHTML: ({ HTMLAttributes }) => ["aside", mergeAttributes(HTMLAttributes, { class: "callout" }), 0],
  });
});

factory(config, ctx) returns an Extension (or array); ctx = { tiptap, locale, t }. To activate it:

  1. Register it (before mount — see load order).
  2. List its name in config.extensions.
  3. Add the name to TIPTAP_EXTRA_EXTENSIONS so Python config validation accepts it, and declare the HTML it emits so the server-side sanitiser keeps it.
TIPTAP_EXTRA_EXTENSIONS = {"callout": {"aside": {"attrs": ["class"]}}}
TipTapWidget(config={"extensions": ["callout"]})

Built-in names are always active; unknown, unregistered names fail loudly at mount.

Writing against Tiptap 3

ctx.tiptap and DjangoTipTap.tiptap hand you Tiptap 3's Editor, Extension, Mark, Node and mergeAttributes. Most extensions written for Tiptap 2 carry over unchanged; these are the changes a custom extension is likely to meet:

  • setContent takes an options object. Write editor.commands.setContent(html, { emitUpdate: false }). A boolean second argument is not rejected: it is read as an options object with nothing in it, so the Tiptap 2 spelling setContent(html, false) now emits an update, which writes the bound textarea.
  • A node view's getPos() can return undefined, for a node that is no longer in the document. Check it before using it as a position.
  • The undo extension is undoRedo, where Tiptap 2 called it history. The undo and redo commands keep their names. In config.extensions, where a built-in name changes nothing because built-ins are always on, it is still history: undoRedo there is refused as an unknown extension.
  • Default exports are not guaranteed. Tiptap 3 dropped them from @tiptap/extension-table and @tiptap/extension-text-style. Take what you need from ctx.tiptap, or import named exports in your own build.

Declaring what an extension emits

The server sanitises stored markup against an allowlist built from the extensions the editor mounts (see Security). It knows what the built-ins emit; it cannot know what yours does, so tell it:

TIPTAP_EXTRA_EXTENSIONS = {
    "callout": {"aside": {"attrs": ["class"], "styles": ["background-color"]}},
    "shortcuts": {},  # emits no markup of its own
}

Each tag maps to the attributes and the inline-style properties your extension puts on it. Style properties go under styles, never as a style entry in attrs — that is what keeps a declared extension from turning style into a passthrough.

A field restricted with features keeps a declared vocabulary only when the field's own extensions names the extension: the field is cleaned against what its editor mounts, and an editor mounts a custom extension only when its config names it. A named extension's vocabulary is admitted as it is declared, whatever the field's features leave out: an extension declaring h2 keeps <h2> on a field without heading that names it. Declare only the tags the extension itself renders.

The plain list form still works and still passes config validation:

TIPTAP_EXTRA_EXTENSIONS = ["callout"]

but it leaves the vocabulary undeclared. Building the schema then warns, naming the extension, and the sanitiser unwraps its tags — the wrapper is dropped on save and the text inside it is kept. Declare the vocabulary, or accept that the markup does not survive a round trip through the server.

Three things are refused outright, with ImproperlyConfigured:

  • a tag that executes script or loads a document (script, style, iframe, object, embed, base, meta, link, form, svg, template, …);
  • a tag whose content a browser parses by rules of its own rather than as ordinary HTML: math, noembed, noframes, plaintext, textarea, title and xmp. The sanitiser would check one reading of the content and the browser render another, so an extension that needs one should emit an ordinary element (a span or div with a class or data attribute) instead;
  • any on* attribute.

The error is raised where the setting is read: when a TipTap form field cleans a value, when the tiptap_html filter renders one, when a widget's config names custom extensions, and when a TipTapJSONField validates a document.

Custom nodes and JSON storage

TIPTAP_EXTRA_EXTENSIONS is also the vocabulary TipTapJSONField validates a document against. A node or mark type outside it is rejected by full_clean(), a ModelForm and the admin, because the server-side renderer that derives the stored html mirror does not know the type and would flatten it to its text content — the wrapper and its attributes would silently disappear from the mirror on the first save.

Declaring the type is you taking that on: the doc keeps it in full, and the derived html mirror still cannot represent it, so render those documents from doc (client-side via DjangoTipTap.renderHTML, or with your own template) rather than from .html. Name the node/mark type, which is not always the extension's registered name:

# a "callout" extension whose Node.create({ name: "calloutBox" }) needs both
TIPTAP_EXTRA_EXTENSIONS = ["callout", "calloutBox"]

Keyboard shortcuts

The Enter key (built in)

Changing what Enter does is common enough to be a first-class config key — no JS required. Set enterKey to "hardBreak" (Enter inserts a <br>) or "swap" (exchange Enter and Shift-Enter); the default "paragraph" keeps the usual split-into-a-new-paragraph behaviour:

TipTapWidget(config={"enterKey": "hardBreak"})

Lists are exempt, in every mode. Inside a list item Enter starts the next item and Shift-Enter breaks the line within it — the behaviour authors expect from every other editor. A mode that inserted a <br> there would leave no way to add a bullet, or to leave the list, from the keyboard. Pressing Enter on an empty item still lifts out of the list as usual.

To make it the default for every editor in the project, set it in the project-wide config — it merges into every instance, no per-field repetition:

# settings.py
TIPTAP_DEFAULT_CONFIG = {"enterKey": "hardBreak"}

Arbitrary shortcuts (custom extension)

For anything beyond Enter, register a keymap-only extension. Give it a high priority so its bindings win over the built-in keymaps, and return the command's result so unhandled cases fall through:

DjangoTipTap.registerExtension("shortcuts", (config, ctx) => {
  const { Extension } = ctx.tiptap;
  return Extension.create({
    name: "shortcuts",
    priority: 1000, // beat the default-100 built-in bindings
    addKeyboardShortcuts() {
      return {
        "Mod-Enter": () => this.editor.commands.setHardBreak(),
        "Mod-s": () => true, // swallow Ctrl/Cmd-S so the browser doesn't "Save Page"
      };
    },
  });
});

Activate it like any custom extension — list "shortcuts" in config.extensions and add it to TIPTAP_EXTRA_EXTENSIONS (and, for a project-wide default, in TIPTAP_DEFAULT_CONFIG). A shortcut-only extension emits no markup, so declare it as {"shortcuts": {}}.

A custom extension that builds on a built-in node (a content expression naming listItem, say) needs that built-in to be mounted. On a field restricted with features, list the built-ins it depends on there, or the editor fails to build.

Toolbar buttons

DjangoTipTap.ui.registerButton("callout", {
  icon: "▣",
  title: "Callout",
  isActive: (editor) => editor.isActive("callout"),
  onClick: (editor) => editor.chain().focus().toggleWrap("callout").run(),
});

Then reference the key in config.toolbar. A button spec is either a command button (icon + onClick, optional isActive / isEnabled) or a custom control (render(editor) -> { el, refresh? }) that owns its DOM — that's how the built-in font/colour/table menus are built.

A spec may also carry requires, the name of the built-in feature it cannot work without (for example "image"). A field restricted with features that leaves that feature out does not render the button. Leave it off for a button that needs no built-in feature. Registering a built-in key again replaces the spec, so the replacement carries its own requires.

Load order

Registration must run before auto-mount. Load your registration script after the editor assets; auto-mount runs on DOMContentLoaded, so a normal script placed after the bundle (or {% tiptap_media %}) registers in time. For dynamically inserted editors, call DjangoTipTap.autoMount(root) after registering, or use explicit init.

Custom locales

DjangoTipTap.registerLocale("de", { bold: "Fett", italic: "Kursiv" /* … */ });

Missing keys fall back to English. Select with config.locale.

Semver

Custom-extension authoring is tied to the supported TipTap major; a TipTap major bump is a major bump here too. See the stability policy.