A Redactor plugin is a class that extends the editor: buttons, commands, events, optional custom blocks. This guide covers structure, loading, registration, blocks, and packaging as an IIFE bundle.
Plugin class#
Minimal plugin:
class GreetingPlugin {
static defaultOptions = {
icon: '<svg …>',
prefix: 'Hello'
};
static defaultLang = {
en: {
greeting: {
title: 'Greeting',
text: 'Hello from plugin!'
}
}
};
constructor(app, options = {}) {
this.app = app;
this.options = options;
}
start() {
this.app.toolbar.add('greeting', {
title: '## greeting.title ##',
icon: this.options.icon,
command: 'greeting.insert',
position: { after: 'add' }
});
}
insert() {
const inserter = this.app.getService('inserter');
const prefix = this.options.prefix || '';
const text = this.app.getLang('greeting.text');
inserter.insertHtml(`<p>${prefix ? prefix + ': ' : ''}${text}</p>`, { caret: 'end' });
}
}
Enable with overrides:
Redactor('#entry', {
plugins: ['greeting'],
greeting: {
prefix: 'Hi there'
}
});
Merged options are available as this.options in the plugin instance.
Lifecycle
Startup order:
constructor(app, options)— plugin instance is created- Editor DOM and toolbox are built; initial content is parsed
start()— modules and plugins start (toolbar exists; use for buttons and UI)load()— afterparser:loaded, when the document is mounted andeditor:readyis about to firestop()— onapp.stop(), teardown
| Method | When |
|---|---|
constructor(app, options) |
Plugin instantiated during editor init |
start() |
After UI is built; register buttons, listen to events |
load() |
After content is parsed and blocks are mounted — late setup that needs a ready document |
stop() |
Editor shutdown |
Most plugins only need start(). Use load() when you must run after the first parse (e.g. scan blocks in the document).
Static properties
| Property | Purpose |
|---|---|
static defaultOptions |
Default plugin settings merged into this.options |
static defaultLang |
i18n strings (## plugin.key ## in titles) |
Access editor settings, modules, and services
From any plugin method, use this.app:
Editor options (global Redactor config):
const lang = this.app.getOption('lang');
const uploadUrl = this.app.getOption('image.upload');
const allOptions = this.app.getOptions();
if (this.app.isOption('image.upload')) {
// image upload is enabled
}
getOption('dotted.path') reads nested keys. isOption treats false, null, and undefined as disabled.
Modules — feature APIs (toolbar, image, parser, dropdown, …):
const toolbar = this.app.getModule('toolbar');
// or shorthand:
this.app.toolbar.add('myBtn', { /* … */ });
this.app.image.insert({ url: '/photo.jpg' }, 'insert');
this.app.dropdown.close();
Services — internal helpers (inserter, transport, blockManager, …):
const inserter = this.app.getService('inserter');
inserter.insertHtml('<p>Text</p>', { caret: 'end' });
const transport = this.app.getService('transport');
transport.get({ url: '/api/data', responseType: 'json', success: (json) => { /* … */ } });
Other plugins:
if (this.app.isPluginActive('counter')) {
this.app.counter.getCount();
}
See API in the docs for module and service reference lists.
Commands
A button command: 'greeting.insert' calls app.greeting.insert() — the plugin instance is exposed on app under the plugin key.
Same via invoke:
app.invoke('greeting.insert');
Plugin key must not match an existing core module name.
Register a plugin#
Global registration
Call before creating the editor:
Redactor.addPlugin('greeting', GreetingPlugin);
Redactor.addPlugin(key, PluginClass) stores the class in Redactor’s global registry. Multiple editors on one page share the same registration.
Enable on an editor
Script tags (IIFE) — load the plugin file after redactor.js, then list the key:
<script src="/assets/redactor/redactor.min.js"></script>
<script src="/assets/redactor/plugins/greeting/greeting.min.js"></script>
<script>
Redactor('#entry', {
plugins: ['greeting']
});
</script>
Several plugins:
Redactor('#entry', {
plugins: ['counter', 'emoji', 'greeting']
});
ES modules — import the ESM core, register your plugin class, enable by key:
import Redactor from './redactor.esm.min.js';
import { GreetingPlugin } from './GreetingPlugin.js'; // your plugin file
Redactor.addPlugin('greeting', GreetingPlugin);
Redactor('#entry', {
plugins: ['greeting']
});
Plugin options
Merge order: defaultOptions → plugins.{key}.options → top-level {key}: { … }.
Redactor('#entry', {
plugins: ['accordion'],
accordion: {
icon: '<svg …>'
}
});
Or inline:
plugins: {
accordion: {
plugin: 'accordion',
options: { icon: '<svg …>' }
}
}
Plugins with custom blocks#
Some plugins add new block types (accordion, mention, math). Register blocks before the plugin:
Redactor.addBlock('accordion', AccordionBlock);
Redactor.addBlock('accordion-item', AccordionItemBlock);
Redactor.addPlugin('accordion', AccordionPlugin);
At editor startup, blocks from Redactor.addBlock() are registered in BlockManager alongside built-in blocks.
Block class basics
Extend Redactor.Block for block-level nodes, or Redactor.InlineBlock for inline tokens (mentions, merge tags).
class AccordionBlock extends Redactor.Block {
static meta = {
type: 'accordion',
label: 'Accordion',
nested: true,
canBeParent: true,
focusable: true,
editable: false,
preserveDataBlock: true
};
static match(el) {
return el?.dataset?.block === 'accordion';
}
createEl() {
const doc = this.app.host.getDocument();
return doc.createElement('div');
}
init(data) {
// apply data when created from JSON or API
}
getData() {
return { ...super.getData() /* type-specific fields */ };
}
}
Important pieces:
| Piece | Role |
|---|---|
static meta |
Type id, nesting, UI overrides, inline: true for inline blocks |
static match(el) |
Detect existing HTML when parsing content |
static shouldSkip(el, { phase }) |
Skip child elements so they are not parsed as separate blocks |
createEl() |
DOM for a new block |
init(data) |
Apply data from JSON / block.create() |
getData() |
Export block fields for getJson() |
Nested blocks (accordion)
Parent block (nested: true, canBeParent: true) holds children. Child blocks use skipNested: true so inner headings/paragraphs stay inside the parent.
The plugin inserts structure programmatically:
insert() {
const accordion = this.app.block.create('accordion');
const item = this.app.block.create('accordion-item', { open: true });
accordion.getEl().appendChild(item.getEl());
const inserter = this.app.getService('inserter');
inserter.insertBlock(accordion);
this.app.blocks.mountBlocksInSubtree(accordion.getEl());
}
Control-dropdown buttons can be declared in block meta.ui.dropdowns (see AccordionItemBlock in the codebase).
Inline blocks (mention)
Inline blocks live inside text (Redactor.InlineBlock, meta.inline: true):
class MentionBlock extends Redactor.InlineBlock {
static meta = {
type: 'mention',
inline: true,
editable: false,
focusable: true,
preserveDataBlock: true
};
static match(el) {
return el.tagName === 'SPAN' && el.dataset.block === 'mention';
}
init(data = {}) {
this.el.dataset.block = 'mention';
this.el.textContent = data.content ?? '';
if (data.userId) this.el.dataset.userId = data.userId;
}
}
The mention plugin registers a trigger (@) and creates blocks via app.block.create('mention', { … }) when the user picks an item — no toolbar button required.
Override without global registration
Pass block classes in editor options (per instance):
Redactor('#entry', {
blocks: {
myCard: MyCardBlock
}
});
Same as Redactor.addBlock but scoped to one editor.
Events and hooks#
Subscribe in the constructor or start():
constructor(app) {
this.app = app;
this.app.on('output:before:html', (event) => {
// return { html } to modify export
return event;
});
}
Hook handlers may return a modified payload. Signal events (blocks:change, mention:insert) are notify-only.
IIFE bundle#
Ship plugins as a separate file that does not pollute the global scope. Wrap everything and pass Redactor in:
(function () {
class GreetingPlugin {
// …
}
Redactor.addPlugin('greeting', GreetingPlugin);
})(Redactor);
Rules:
- Load
redactor.js(orredactor.min.js) first — the IIFE receives the globalRedactorconstructor. - Do not use
import/exportinside the IIFE file — inline classes or a bundler that outputs IIFE. - Call
Redactor.addPlugin(andRedactor.addBlockif needed) inside the wrapper. - Avoid leaking variables —
const/classinside the function stay private.
IIFE with blocks
Plugins with blocks read base classes from Redactor:
(function () {
const Block = globalThis.Redactor?.Block;
if (!Block) {
throw new Error('[My plugin] Load redactor.js before this bundle.');
}
class AccordionBlock extends Block {
static meta = { type: 'accordion', /* … */ };
// …
}
class AccordionPlugin {
// …
}
Redactor.addBlock('accordion', AccordionBlock);
Redactor.addPlugin('accordion', AccordionPlugin);
})(Redactor);
For inline blocks use Redactor.InlineBlock. Built-in bases exported on Redactor: Block, InlineBlock, CommonBlock.
Plugin-only IIFE (no blocks)
Same pattern as the anchor plugin — only the plugin class:
(function () {
class AnchorPlugin {
start() {
this.app.toolbar.add('anchor', { /* … */ });
}
// …
}
Redactor.addPlugin('anchor', AnchorPlugin);
})(Redactor);
Build tips
- Name the output file after the plugin key, e.g.
plugins/greeting/greeting.min.js. - One plugin per IIFE file keeps load order predictable.
- If you use a bundler (esbuild, Rollup), set
format: 'iife'and wrap with(function () { … })(Redactor);. - In ESM apps, import
redactor.esm.min.js, setglobalThis.Redactor, load your IIFE plugin script, then enable it withplugins: ['greeting'].
Built plugins ship only as files under plugins/ — not as an importable class tree.