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:
- Register it (before mount — see load order).
- List its name in
config.extensions. - Add the name to
TIPTAP_EXTRA_EXTENSIONSso 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:
setContenttakes an options object. Writeeditor.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 spellingsetContent(html, false)now emits an update, which writes the bound textarea.- A node view's
getPos()can returnundefined, 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 ithistory. Theundoandredocommands keep their names. Inconfig.extensions, where a built-in name changes nothing because built-ins are always on, it is stillhistory:undoRedothere is refused as an unknown extension. - Default exports are not guaranteed. Tiptap 3 dropped them from
@tiptap/extension-tableand@tiptap/extension-text-style. Take what you need fromctx.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:
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,titleandxmp. 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 (aspanordivwith 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:
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:
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¶
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.