Technical reference for creating custom SunEditor plugins. Covers all plugin types, hooks, modules, and patterns for both JavaScript and TypeScript.
- Overview
- Quick Start
- Plugin Types
- Static Properties
- Constructor Pattern
- Dependency Bag (
this.$) - Hooks Reference
- Multi-Interface Pattern
- Modules Reference
- Complete Examples
- Plugin Registration
All plugins extend a base class from src/interfaces/plugins.js. The inheritance chain:
KernelInjector → Base → PluginCommand / PluginModal / PluginDropdown / ...
KernelInjector— Receives the Kernel and exposesthis.$(Deps bag — the shared dependency object, not the Kernel itself).Base— Adds common static properties (key,type,className,options) and instance properties (title,icon,inner,beforeItem,afterItem,replaceButton).- Plugin type class — Defines required abstract methods per plugin type.
- Class references, not instances — Register plugin classes in
options.plugins. The Kernel instantiates them. - Dependency injection — All editor services are accessed via
this.$(the Deps bag), never import core modules directly. - Contracts via interfaces — Plugins can implement multiple contracts (e.g.,
ModuleModal,EditorComponent) to hook into module lifecycles.
options.plugins: [MyPlugin]
↓
PluginManager.init()
↓
new MyPlugin(kernel, pluginOptions) → super(kernel) → this.$ = kernel.$ (Deps bag)
↓
Toolbar buttons updated (title, icon)
↓
Event hooks registered and sorted by priority
↓
Component checkers registered (if static component() exists)
import { PluginCommand } from 'suneditor/src/interfaces';
import { dom } from 'suneditor/src/helper';
class HelloWorld extends PluginCommand {
static key = 'helloWorld';
/**
* @constructor
* @param {SunEditor.Kernel} kernel - The Kernel instance
*/
constructor(kernel) {
super(kernel);
this.title = 'Hello World';
this.icon = '<span style="font-size:14px">HW</span>';
}
/**
* @override
* @type {PluginCommand['action']}
*/
action() {
this.$.html.insert('<p>Hello, World!</p>');
this.$.history.push(false);
}
}
export default HelloWorld;import { PluginCommand } from 'suneditor/src/interfaces';
import type { SunEditor } from 'suneditor/types';
class HelloWorld extends PluginCommand {
static key = 'helloWorld';
constructor(kernel: SunEditor.Kernel) {
super(kernel);
this.title = 'Hello World';
this.icon = '<span style="font-size:14px">HW</span>';
}
action(): void {
this.$.html.insert('<p>Hello, World!</p>');
this.$.history.push(false);
}
}
export default HelloWorld;import SUNEDITOR from 'suneditor';
import plugins from 'suneditor/src/plugins';
import HelloWorld from './plugins/helloWorld';
SUNEDITOR.create('editor', {
plugins: [...plugins, HelloWorld],
buttonList: [['bold', 'italic', 'helloWorld']],
});Source: src/interfaces/plugins.js
| Base Class | static type |
Required Methods | UI Behavior | Examples |
|---|---|---|---|---|
PluginCommand |
command |
action() |
Button click executes action | blockquote, list_bulleted |
PluginDropdown |
dropdown |
action() |
Button opens menu, item click calls action() |
align, font, blockStyle |
PluginDropdownFree |
dropdown-free |
— | Button opens menu, plugin handles own events | table, fontColor |
PluginModal |
modal |
open() |
Button opens modal dialog | link, image, video |
PluginBrowser |
browser |
open(), close() |
Button opens gallery browser | imageGallery |
PluginField |
field |
— | Responds to editor input events | mention |
PluginInput |
input |
— | Toolbar input element | fontSize |
PluginPopup |
popup |
show() |
Inline popup context menu | anchor |
Simplest type. Executes immediately on button click.
import { PluginCommand } from 'suneditor/src/interfaces';
import { dom } from 'suneditor/src/helper';
class ToggleStrikethrough extends PluginCommand {
static key = 'toggleStrikethrough';
/**
* @constructor
* @param {SunEditor.Kernel} kernel - The Kernel instance
*/
constructor(kernel) {
super(kernel);
this.title = 'Strikethrough';
this.icon = 'strikethrough'; // built-in icon key, or raw SVG/HTML
}
/**
* @hook Editor.EventManager
* @type {SunEditor.Hook.Event.Active}
*/
active(element, target) {
if (/^S$/i.test(element?.nodeName)) {
dom.utils.addClass(target, 'active');
return true;
}
dom.utils.removeClass(target, 'active');
return false;
}
/**
* @override
* @type {PluginCommand['action']}
*/
action() {
const node = dom.utils.createElement('S');
this.$.inline.apply(node, { stylesToModify: null, nodesToRemove: null });
}
}Opens a dropdown menu. on() is called when the menu opens, action() when an item is clicked.
import { PluginDropdown } from 'suneditor/src/interfaces';
import { dom } from 'suneditor/src/helper';
/**
* @typedef {Object} CustomAlignPluginOptions
* @property {Array.<"right"|"center"|"left"|"justify">} [items] - Align items
*/
class CustomAlign extends PluginDropdown {
static key = 'customAlign';
/**
* @constructor
* @param {SunEditor.Kernel} kernel - The Kernel instance
* @param {CustomAlignPluginOptions} pluginOptions
*/
constructor(kernel, pluginOptions) {
super(kernel);
this.title = this.$.lang.align;
this.icon = 'align_left';
// Build dropdown HTML
const menu = dom.utils.createElement(
'div',
{ class: 'se-dropdown se-list-layer' },
`<div class="se-list-inner">
<ul class="se-list-basic">
<li><button type="button" class="se-btn se-btn-list" data-command="left">Left</button></li>
<li><button type="button" class="se-btn se-btn-list" data-command="center">Center</button></li>
<li><button type="button" class="se-btn se-btn-list" data-command="right">Right</button></li>
</ul>
</div>`,
);
// Register dropdown target
this.$.menu.initDropdownTarget(CustomAlign, menu);
}
/**
* @override
* @type {PluginDropdown['on']}
*/
on(target) {
// Called when dropdown opens. Update active states.
}
/**
* @override
* @type {PluginDropdown['action']}
*/
action(target) {
const value = target.getAttribute('data-command');
if (!value) return;
const lines = this.$.format.getLines();
for (const line of lines) {
dom.utils.setStyle(line, 'textAlign', value);
}
this.$.menu.dropdownOff();
this.$.focusManager.focus();
this.$.history.push(false);
}
}Like dropdown, but the plugin handles its own event logic. No automatic action() dispatch.
import { PluginDropdownFree } from 'suneditor/src/interfaces';
class CustomPicker extends PluginDropdownFree {
static key = 'customPicker';
/**
* @constructor
* @param {SunEditor.Kernel} kernel - The Kernel instance
*/
constructor(kernel) {
super(kernel);
this.title = 'Custom Picker';
this.icon = 'color';
const menu = /* build your custom UI */;
this.$.menu.initDropdownTarget(CustomPicker, menu);
// Attach your own event listeners
this.$.eventManager.addEvent(menu, 'click', this.#handleClick.bind(this));
}
on(target) {
// Called when dropdown opens
}
off() {
// Called when dropdown closes — cleanup state
}
#handleClick(e) {
// Your own event handling logic
this.$.menu.dropdownOff();
}
}Opens a modal dialog. Use with the Modal module.
import { PluginModal } from 'suneditor/src/interfaces';
import Modal from 'suneditor/src/modules/contract/Modal';
import { dom } from 'suneditor/src/helper';
class InsertCode extends PluginModal {
static key = 'insertCode';
/**
* @constructor
* @param {SunEditor.Kernel} kernel - The Kernel instance
*/
constructor(kernel) {
super(kernel);
this.title = 'Insert Code';
this.icon = 'code';
// Build modal HTML
const modalEl = dom.utils.createElement(
'div',
null,
`<form>
<div class="se-modal-header"><button type="button" data-command="close" class="se-btn se-modal-close"></button>
<span class="se-modal-title">Insert Code</span>
</div>
<div class="se-modal-body">
<textarea class="se-input-form" style="height:200px"></textarea>
</div>
<div class="se-modal-footer">
<button type="submit" class="se-btn-primary"><span>Insert</span></button>
</div>
</form>`,
);
this.modal = new Modal(this, this.$, modalEl);
this.textarea = modalEl.querySelector('textarea');
}
/**
* @override
* @type {PluginModal['open']}
*/
open() {
this.modal.open();
}
/**
* @hook Modules.Modal
* @type {SunEditor.Hook.Modal.Action}
*/
async modalAction() {
const code = this.textarea.value;
if (!code) return false; // close loading only
const pre = dom.utils.createElement('PRE');
const codeEl = dom.utils.createElement('CODE');
codeEl.textContent = code;
pre.appendChild(codeEl);
this.$.html.insert(pre.outerHTML);
this.$.history.push(false);
return true; // close modal + loading
}
/**
* @hook Modules.Modal
* @type {SunEditor.Hook.Modal.On}
*/
modalOn(isUpdate) {
if (!isUpdate) this.textarea.value = '';
this.textarea.focus();
}
/**
* @hook Modules.Modal
* @type {SunEditor.Hook.Modal.Off}
*/
modalOff() {
this.textarea.value = '';
}
}Opens a gallery/browser interface. Requires open() and close().
import { PluginBrowser } from 'suneditor/src/interfaces';
import Browser from 'suneditor/src/modules/contract/Browser';
class MyGallery extends PluginBrowser {
static key = 'myGallery';
/**
* @constructor
* @param {SunEditor.Kernel} kernel - The Kernel instance
*/
constructor(kernel) {
super(kernel);
this.title = 'My Gallery';
this.icon = 'image';
this.browser = new Browser(this, this.$ /* browser config */);
}
open(onSelectFunction) {
this.browser.open(onSelectFunction);
}
close() {
this.browser.close();
}
}Responds to editor input events. Commonly uses onInput hook to detect trigger patterns.
import { PluginField } from 'suneditor/src/interfaces';
import { converter } from 'suneditor/src/helper';
class HashtagDetector extends PluginField {
static key = 'hashtagDetector';
static className = '';
/**
* @constructor
* @param {SunEditor.Kernel} kernel - The Kernel instance
*/
constructor(kernel) {
super(kernel);
this.onInput = converter.debounce(this.onInput.bind(this), 200);
}
/**
* @hook Editor.EventManager
* @type {SunEditor.Hook.Event.OnInput}
*/
onInput({ frameContext }) {
const sel = this.$.selection.get();
const text = sel.anchorNode?.textContent || '';
const before = text.substring(0, sel.anchorOffset);
const match = before.match(/#(\w+)$/);
if (match) {
// Handle hashtag detection
console.log('Detected hashtag:', match[1]);
}
}
}Adds an input element to the toolbar instead of a button.
import { PluginInput } from 'suneditor/src/interfaces';
class CustomInput extends PluginInput {
static key = 'customInput';
/**
* @constructor
* @param {SunEditor.Kernel} kernel - The Kernel instance
*/
constructor(kernel) {
super(kernel);
this.title = 'Custom Input';
}
/**
* @override
* @type {PluginInput['toolbarInputKeyDown']}
*/
toolbarInputKeyDown({ target, event }) {
if (event.key === 'Enter') {
event.preventDefault();
const value = target.value;
// Handle the input value
}
}
/**
* @override
* @type {PluginInput['toolbarInputChange']}
*/
toolbarInputChange({ target, value }) {
// Handle input blur/change
}
}Shows inline popup menus (e.g., for link preview).
import { PluginPopup } from 'suneditor/src/interfaces';
class InfoPopup extends PluginPopup {
static key = 'infoPopup';
/**
* @constructor
* @param {SunEditor.Kernel} kernel - The Kernel instance
*/
constructor(kernel) {
super(kernel);
this.title = 'Info';
}
show() {
// Display popup UI
}
}Every plugin class can define these static properties:
| Property | Type | Required | Description |
|---|---|---|---|
key |
string |
Yes | Unique plugin identifier. Must match the name used in buttonList. |
type |
string |
Inherited | Set by the base class. Do not override. |
className |
string |
No | CSS class added to the plugin's toolbar button. |
options |
object |
No | Plugin behavior options. See below. |
component(node) |
function |
No* | Static method to detect component DOM nodes. *Required if implementing EditorComponent. |
class MyPlugin extends PluginField {
static options = {
eventIndex: 100, // Default priority for all event hooks (lower = earlier)
eventIndex_onKeyDown: 50, // Per-event override
eventIndex_onInput: 200, // Higher = later execution
isInputComponent: true, // Allow keyboard input inside component (e.g., table cells)
};
}Required for plugins implementing the EditorComponent contract. Returns the component element if the node belongs to this plugin, or null otherwise.
class MyImagePlugin extends PluginModal {
static component(node) {
return /^IMG$/i.test(node?.nodeName) ? node : null;
}
}Plugin options are defined as a @typedef above the class, and the constructor receives kernel (Kernel instance) + pluginOptions:
/**
* @typedef {Object} MyPluginOptions
* @property {boolean} [canResize=true] - Whether the element can be resized.
* @property {string} [defaultWidth="auto"] - The default width.
*/
/**
* @class
* @description MyPlugin description.
*/
class MyPlugin extends PluginModal {
static key = 'myPlugin';
static className = 'se-btn-my-plugin';
/**
* @constructor
* @param {SunEditor.Kernel} kernel - The Kernel instance
* @param {MyPluginOptions} pluginOptions
*/
constructor(kernel, pluginOptions) {
super(kernel); // KernelInjector → this.$ = kernel.$ (Deps bag)
// Plugin metadata (used by toolbar button)
this.title = this.$.lang.myPlugin || 'My Plugin';
this.icon = 'myPlugin'; // icon key from this.$.icons, or raw HTML/SVG
// Optional: toolbar button content and layout
this.inner = null; // string (HTML) | HTMLElement | false (hide) | null (use icon)
this.beforeItem = null; // HTMLElement to insert before the button
this.afterItem = null; // HTMLElement to insert after the button
this.replaceButton = null; // HTMLElement to replace the entire default button
// Plugin members
this.myState = {};
// Module instances (if using Modal, Controller, etc.)
this.modal = new Modal(this, this.$, modalElement);
this.controller = new Controller(this, this.$, controllerElement, { position: 'bottom' });
}
}Parameters:
kernel(SunEditor.Kernel) — The Kernel instance (runtime container). Pass tosuper()to injectthis.$(Deps bag).pluginOptions(object) — Plugin-specific options fromoptions[pluginKey]. Define a@typedeffor type checking.
All plugins access editor services through this.$ (the Deps bag). This shared dependency object is built once by the Kernel (CoreKernel) and provided to all consumers. $ is not the Kernel itself — it is the dependency context that the Kernel provides.
Source: src/core/kernel/kernelInjector.js
| Property | Type | Description |
|---|---|---|
options |
Map |
Global editor options (shared across all frames) |
frameOptions |
Map |
Current frame's options (width, height, placeholder, etc.) |
context |
Map |
Global context (toolbar, statusbar, modal overlay elements) |
frameContext |
Map |
Current frame context (wysiwyg, code, readonly state, etc.) |
frameRoots |
Map |
All frame contexts keyed by rootKey |
lang |
object |
Language strings (e.g., this.$.lang.image) |
icons |
object |
Icon HTML strings (e.g., this.$.icons.bold) |
| Property | Description |
|---|---|
selection |
Selection and range manipulation |
html |
HTML get/set, insert, sanitization |
format |
Block-level formatting (applyBlock, removeBlock, getLines) |
inline |
Inline formatting (bold, italic, styles) |
listFormat |
List operations (create, edit, nested) |
nodeTransform |
DOM node transformations |
char |
Character counting and limits |
offset |
Position calculations |
| Property | Description |
|---|---|
component |
Component lifecycle (select, deselect, setInfo) |
focusManager |
Focus/blur management |
pluginManager |
Plugin registry and lifecycle |
plugins |
Plugin instances map (e.g., this.$.plugins.image) |
ui |
UI state (loading, alerts, toast, theme) |
commandDispatcher |
Command routing and execution |
history |
Undo/redo stack (push, undo, redo) |
shortcuts |
Keyboard shortcut mapping |
| Property | Description |
|---|---|
toolbar |
Main toolbar renderer and positioning |
subToolbar |
Sub-toolbar (only with _subMode) |
menu |
Dropdown menu management (initDropdownTarget, dropdownOff) |
viewer |
View modes (code view, fullscreen, preview) |
| Property | Description |
|---|---|
eventManager |
Public event API (addEvent, removeEvent, triggerEvent) |
contextProvider |
Context/FrameContext Map management |
optionProvider |
Options/FrameOptions Map management |
instanceCheck |
Iframe-safe instanceof checks |
store |
Central runtime state store |
facade |
The editor public API instance |
Hooks are methods that the editor core or modules call on plugin instances at specific lifecycle points.
All hook methods should be annotated with JSDoc tags to enable type checking and IDE support. There are three annotation patterns:
Used for methods called by the editor core or modules. The @hook tag indicates which system calls the method, and @type provides the type signature.
/**
* @hook Editor.EventManager
* @type {SunEditor.Hook.Event.Active}
*/
active(element, target) { ... }
/**
* @hook Editor.EventManager
* @type {SunEditor.Hook.Event.OnKeyDown}
*/
onKeyDown({ frameContext, event, range, line }) { ... }
/**
* @hook Editor.Core
* @type {SunEditor.Hook.Core.Shortcut}
*/
shortcut({ range, info }) { ... }
/**
* @hook Modules.Modal
* @type {SunEditor.Hook.Modal.Action}
*/
async modalAction() { ... }
/**
* @hook Editor.Component
* @type {SunEditor.Hook.Component.Select}
*/
componentSelect(target) { ... }Available @hook categories and their @type namespaces:
@hook Category |
@type Namespace |
Methods |
|---|---|---|
Editor.EventManager |
SunEditor.Hook.Event.* |
Active, OnKeyDown, OnInput, OnClick, OnPaste, OnFocus, OnBlur ... |
Editor.Core |
SunEditor.Hook.Core.* |
RetainFormat, Shortcut, SetDir, Init |
Editor.Component |
SunEditor.Hook.Component.* |
Select, Deselect, Edit, Destroy, Copy |
Modules.Modal |
SunEditor.Hook.Modal.* |
Action, On, Init, Off, Resize |
Modules.Controller |
SunEditor.Hook.Controller.* |
Action, On, Close |
Modules.ColorPicker |
SunEditor.Hook.ColorPicker.* |
Action, HueSliderOpen, HueSliderClose |
Modules.HueSlider |
SunEditor.Hook.HueSlider.* |
Action, CancelAction |
Used when overriding required/optional methods from the base plugin class:
/**
* @override
* @type {PluginModal['open']}
*/
open(target) { ... }
/**
* @override
* @type {PluginInput['toolbarInputKeyDown']}
*/
toolbarInputKeyDown({ target, event }) { ... }Used when a plugin implements methods from another plugin type via @implements (see extends vs implements):
/**
* @imple Command
* @type {PluginCommand['action']}
*/
action(target) { ... }
/**
* @imple Dropdown
* @type {PluginDropdown['on']}
*/
on(target) { ... }Source: src/hooks/base.js — Core object
These can be implemented by any plugin type.
| Hook | When Called | Return | Description |
|---|---|---|---|
active(element, target) |
Cursor position changes | boolean | undefined |
Update toolbar button active state. Return true if active, false if not, undefined to stop calling for this scope. |
init() |
Editor init / resetOptions |
void |
Re-initialize plugin state. |
retainFormat() |
HTML cleaning/validation | {query, method} |
Return a CSS selector query and a method(element) to validate/preserve component format. |
shortcut(params) |
Shortcut key triggered | void |
Handle custom keyboard shortcuts. Params: { range, line, info, event, keyCode, $ } |
setDir(dir) |
RTL/LTR direction change | void |
Adjust plugin UI for direction change. dir is 'rtl' or 'ltr'. |
active() example:
/**
* @hook Editor.EventManager
* @type {SunEditor.Hook.Event.Active}
*/
active(element, target) {
if (/^BLOCKQUOTE$/i.test(element?.nodeName)) {
dom.utils.addClass(target, 'active');
return true;
}
dom.utils.removeClass(target, 'active');
return false;
}Source: src/hooks/base.js — Event object
Any plugin can implement event hooks. Method names use the lowercase on prefix.
Interruptible Events — Returning a boolean stops the event hook loop:
false— Stops remaining plugins and prevents default editor behaviortrue— Stops remaining plugins, allows default editor behaviorvoid/undefined— Continues to next plugin
| Hook | Interruptible | Params Type | Description |
|---|---|---|---|
onKeyDown |
Yes | HookParams.KeyEvent |
Key down in editor |
onKeyUp |
Yes | HookParams.KeyEvent |
Key up in editor |
onMouseDown |
Yes | HookParams.MouseEvent |
Mouse down in editor |
onClick |
Yes | HookParams.MouseEvent |
Click in editor |
onPaste |
Yes | HookParams.Paste |
Paste event |
onBeforeInput |
No | HookParams.InputWithData |
Before input processing |
onInput |
No | HookParams.InputWithData |
After input processing |
onMouseUp |
No | HookParams.MouseEvent |
Mouse up in editor |
onMouseMove |
No | HookParams.MouseEvent |
Mouse move in editor |
onMouseLeave |
No | HookParams.MouseEvent |
Mouse leave editor |
onScroll |
No | HookParams.Scroll |
Editor scroll |
onFocus |
No | HookParams.FocusBlur |
Editor focus |
onBlur |
No | HookParams.FocusBlur |
Editor blur |
onFilePasteAndDrop |
No | HookParams.FilePasteDrop |
File paste/drop |
Parameter Types (from src/hooks/params.js):
| Type | Properties |
|---|---|
HookParams.MouseEvent |
{ frameContext, event } |
HookParams.KeyEvent |
{ frameContext, event, range, line } |
HookParams.FocusBlur |
{ frameContext, event } |
HookParams.Scroll |
{ frameContext, event } |
HookParams.InputWithData |
{ frameContext, event, data } |
HookParams.Paste |
{ frameContext, event, data, doc } |
HookParams.FilePasteDrop |
{ frameContext, event, file } |
HookParams.Shortcut |
{ range, line, info, event, keyCode, $ } |
HookParams.CopyComponent |
{ event, cloneContainer, info } |
Async variants: Every event hook can be async. The PluginManager calls both sync and async versions.
Execution priority:
class MyPlugin extends PluginField {
static options = {
eventIndex: 100, // Default priority (lower = earlier)
eventIndex_onKeyDown: 50, // Override: run onKeyDown earlier
eventIndex_onInput: 200, // Override: run onInput later
};
/**
* @hook Editor.EventManager
* @type {SunEditor.Hook.Event.OnKeyDown}
*/
onKeyDown({ event, range }) {
if (event.key === 'Tab') {
event.preventDefault();
this.$.html.insert(' ');
return false; // Stop further processing
}
}
}Source: src/interfaces/contracts.js
When a plugin uses a module (Modal, Controller, etc.), it implements the corresponding contract interface to receive callbacks.
For plugins using the Modal module.
| Hook | Required | When Called | Return |
|---|---|---|---|
modalAction() |
Yes | Form submit | Promise<boolean> — true: close modal + loading, false: close loading only, undefined: close modal only |
modalOn(isUpdate) |
No | After modal opens | void — isUpdate: editing existing (true) vs creating new (false) |
modalInit() |
No | Before modal opens/closes | void |
modalOff(isUpdate) |
No | After modal closes | void |
modalResize() |
No | Modal window resized | void |
For plugins using the Controller module (floating toolbar).
| Hook | Required | When Called | Return |
|---|---|---|---|
controllerAction(target) |
Yes | Controller button clicked | void — target: the clicked button element |
controllerOn(form, target) |
No | After controller opens | void |
controllerClose() |
No | Before controller closes | void |
For plugins using the ColorPicker module.
| Hook | Required | When Called | Return |
|---|---|---|---|
colorPickerAction(color) |
No | Color selected | void |
colorPickerHueSliderOpen() |
No | Hue slider opened | void |
colorPickerHueSliderClose() |
No | Hue slider cancelled | void |
For plugins using the HueSlider module.
| Hook | Required | When Called | Return |
|---|---|---|---|
hueSliderAction() |
Yes | Color selected in slider | void |
hueSliderCancelAction() |
No | Hue slider cancelled | void |
For plugins using the Browser module.
| Hook | Required | When Called | Return |
|---|---|---|---|
browserInit() |
No | Browser opened | void |
Source: src/interfaces/contracts.js
For plugins that create static components in the editor (e.g., images, videos, tables, embeds).
Requirements:
- Define
static component(node)— Returns the component element if the node belongs to this plugin, ornull. - Define a public
_elementproperty — References the currently controlled DOM element. Used to detect clicks and prevent accidental controller closure.
| Hook | Required | When Called | Return |
|---|---|---|---|
componentSelect(target) |
Yes | Component selected (clicked) | void | boolean — Return true for special non-figure components |
componentDeselect(target) |
No | Component deselected | void |
componentEdit(target) |
No | Component edit button clicked | void |
componentDestroy(target) |
No | Component deleted | Promise<void> |
componentCopy(params) |
No | Component copy requested | boolean | void — Return false to cancel copy |
Component detection flow:
User clicks element in editor
↓
PluginManager.findComponentInfo(element)
↓
Calls each plugin's static component(node) method
↓
First non-null result wins → { target, pluginName, options }
↓
plugin.componentSelect(target) called
A single plugin can combine a base type with multiple contract interfaces. This is useful for plugins that need a modal dialog, a floating controller, and component lifecycle management.
Use implements to compose interfaces:
import { interfaces } from 'suneditor';
import type { SunEditor } from 'suneditor/types';
import Modal from 'suneditor/src/modules/contract/Modal';
import Controller from 'suneditor/src/modules/contract/Controller';
class CustomEmbed extends interfaces.PluginModal
implements interfaces.ModuleModal, interfaces.ModuleController, interfaces.EditorComponent
{
static key = 'customEmbed';
_element: HTMLElement | null = null;
modal: InstanceType<typeof Modal>;
controller: InstanceType<typeof Controller>;
constructor(kernel: SunEditor.Kernel) {
super(kernel);
this.title = 'Custom Embed';
this.icon = 'embed';
const modalEl = /* build modal HTML */;
const controllerEl = /* build controller HTML */;
this.modal = new Modal(this, this.$, modalEl);
this.controller = new Controller(this, this.$, controllerEl);
}
// Static: detect embed components
static component(node: Node): Node | null {
return /^IFRAME$/i.test(node?.nodeName) ? node : null;
}
/** @override PluginModal */
open(target?: HTMLElement): void {
this.modal.open();
}
/** @hook Modules.Modal — Action */
async modalAction(): Promise<boolean> {
// Handle form submission
this.$.history.push(false);
return true;
}
/** @hook Modules.Modal — On */
modalOn(isUpdate: boolean): void {
// Initialize modal state
}
/** @hook Modules.Modal — Off */
modalOff(isUpdate: boolean): void {
// Cleanup
}
/** @hook Modules.Controller — Action */
controllerAction(target: HTMLElement): void {
const command = target.getAttribute('data-command');
if (command === 'edit') this.modal.open();
if (command === 'delete') this.componentDestroy(this._element!);
}
/** @hook Modules.Controller — Close */
controllerClose(): void {
// Cleanup on controller close
}
/** @hook Editor.Component — Select */
componentSelect(target: HTMLElement): void {
this._element = target;
this.controller.open(target, null, { isWWTarget: false });
}
/** @hook Editor.Component — Deselect */
componentDeselect(target: HTMLElement): void {
this._element = null;
}
/** @hook Editor.Component — Destroy */
async componentDestroy(target: HTMLElement): Promise<void> {
const container = target.parentElement;
container?.remove();
this._element = null;
this.$.focusManager.focus();
this.$.history.push(false);
}
}In JavaScript, simply implement the methods — no implements keyword needed. The editor calls methods by name regardless of declared interfaces.
import { PluginModal } from 'suneditor/src/interfaces';
import Modal from 'suneditor/src/modules/contract/Modal';
import Controller from 'suneditor/src/modules/contract/Controller';
class CustomEmbed extends PluginModal {
static key = 'customEmbed';
static component(node) {
return /^IFRAME$/i.test(node?.nodeName) ? node : null;
}
/**
* @constructor
* @param {SunEditor.Kernel} kernel - The Kernel instance
*/
constructor(kernel) {
super(kernel);
this._element = null;
this.modal = new Modal(this, this.$, modalEl);
this.controller = new Controller(this, this.$, controllerEl);
}
/**
* @override
* @type {PluginModal['open']}
*/
open() {
this.modal.open();
}
/**
* @hook Modules.Modal
* @type {SunEditor.Hook.Modal.Action}
*/
async modalAction() {
/* ... */ return true;
}
/**
* @hook Modules.Modal
* @type {SunEditor.Hook.Modal.On}
*/
modalOn(isUpdate) {
/* ... */
}
/**
* @hook Modules.Modal
* @type {SunEditor.Hook.Modal.Off}
*/
modalOff(isUpdate) {
/* ... */
}
/**
* @hook Modules.Controller
* @type {SunEditor.Hook.Controller.Action}
*/
controllerAction(target) {
/* ... */
}
/**
* @hook Editor.Component
* @type {SunEditor.Hook.Component.Select}
*/
componentSelect(target) {
this._element = target;
}
/**
* @hook Editor.Component
* @type {SunEditor.Hook.Component.Deselect}
*/
componentDeselect(target) {
this._element = null;
}
/**
* @hook Editor.Component
* @type {SunEditor.Hook.Component.Destroy}
*/
async componentDestroy(target) {
/* ... */
}
}Key insight: TypeScript
implementsonly provides compile-time type checking — it enforces that you implement all required methods with correct signatures. At runtime, the behavior is identical to JavaScript.
A plugin uses extends and implements for different purposes:
| Keyword | Purpose | Multiplicity |
|---|---|---|
extends |
Inherit from a plugin base class (determines the plugin type) | Exactly one |
implements |
Compose additional interfaces (module contracts or other plugin types) | Zero or more |
Every plugin extends exactly one base class. This determines its primary type and lifecycle:
extends PluginModal → type: 'modal' (required: open())
extends PluginCommand → type: 'command' (required: action())
extends PluginInput → type: 'input' (optional: toolbarInputKeyDown/Change)
extends PluginDropdown → type: 'dropdown' (required: action())
Plugins implements module contracts to hook into module lifecycles:
class Image extends PluginModal
implements ModuleModal, ModuleController, EditorComponent { ... }ModuleModal→modalAction(),modalOn(),modalOff()ModuleController→controllerAction(),controllerOn()EditorComponent→componentSelect(),componentDestroy()
A plugin can also implements other plugin type interfaces to provide multiple interaction modes. The fontSize plugin is a representative example:
FontSize extends PluginInput ← base type (toolbar input)
@implements {PluginCommand} ← provides action() for inc/dec buttons
@implements {PluginDropdown} ← provides on() for dropdown menu
JavaScript — Use @implements JSDoc tags for type hints:
import { PluginCommand, PluginDropdown, PluginInput } from 'suneditor/src/interfaces';
void PluginCommand;
void PluginDropdown;
/**
* @implements {PluginCommand}
* @implements {PluginDropdown}
*/
class FontSize extends PluginInput {
static key = 'fontSize';
// PluginInput base
toolbarInputKeyDown(params) {
/* handle arrow keys, enter */
}
toolbarInputChange(params) {
/* apply typed value */
}
// PluginCommand (implements) — inc/dec button clicks
action(target) {
/* adjust font size */
}
// PluginDropdown (implements) — dropdown open
on(target) {
/* highlight active size in list */
}
}TypeScript — Use implements keyword:
import { interfaces } from 'suneditor';
import type { SunEditor } from 'suneditor/types';
class FontSize extends interfaces.PluginInput
implements interfaces.PluginCommand, interfaces.PluginDropdown
{
static key = 'fontSize';
toolbarInputKeyDown(params: SunEditor.HookParams.ToolbarInputKeyDown): void { ... }
toolbarInputChange(params: SunEditor.HookParams.ToolbarInputChange): void { ... }
action(target: HTMLElement): void { ... }
on(target: HTMLElement): void { ... }
}When to use cross-plugin implements: When a single plugin provides multiple interaction modes (e.g., an input field + dropdown menu + command buttons all controlling the same feature). The base
extendsdetermines the primary type;implementsadds methods from other plugin types that the editor calls by name.
Source: src/modules/
Modules are UI components that plugins instantiate manually. They are not auto-registered.
| Module | Import Path | Constructor | Purpose |
|---|---|---|---|
Modal |
modules/contract/Modal |
new Modal(inst, $, element) |
Dialog windows |
Controller |
modules/contract/Controller |
new Controller(inst, $, element, options?) |
Floating tooltip controllers |
Figure |
modules/contract/Figure |
new Figure(inst, $, controls, options?) |
Resize/align wrapper for components |
ColorPicker |
modules/contract/ColorPicker |
new ColorPicker(inst, $, ...) |
Color palette UI |
HueSlider |
modules/contract/HueSlider |
new HueSlider(inst, $, ...) |
HSL color wheel |
Browser |
modules/contract/Browser |
new Browser(inst, $, ...) |
Gallery/file browser UI |
FileManager |
modules/manager/FileManager |
new FileManager(inst, $, options) |
File upload management |
ApiManager |
modules/manager/ApiManager |
new ApiManager(inst, $, ...) |
XHR/fetch request management |
SelectMenu |
modules/ui/SelectMenu |
new SelectMenu(...) |
Custom dropdown select menus |
ModalAnchorEditor |
modules/ui/ModalAnchorEditor |
new ModalAnchorEditor($, modal, options) |
Link/anchor editing form |
Constructor pattern: All contract modules receive:
inst— The plugin instance (for calling hook methods back on the plugin)$— The deps bag- Module-specific parameters (HTML element, options, etc.)
A command plugin that shows the current word count.
import { PluginCommand } from 'suneditor/src/interfaces';
class WordCount extends PluginCommand {
static key = 'wordCount';
/**
* @constructor
* @param {SunEditor.Kernel} kernel - The Kernel instance
*/
constructor(kernel) {
super(kernel);
this.title = 'Word Count';
this.icon = '<span style="font-size:12px;font-weight:bold">WC</span>';
}
/**
* @override
* @type {PluginCommand['action']}
*/
action() {
const text = this.$.html.get({ format: 'text' });
const words = text.trim().split(/\s+/).filter(Boolean).length;
this.$.ui.showToast(`Words: ${words}`, 2000);
}
}
export default WordCount;A dropdown plugin that applies predefined block styles.
import { PluginDropdown } from 'suneditor/src/interfaces';
import { dom } from 'suneditor/src/helper';
class QuickStyle extends PluginDropdown {
static key = 'quickStyle';
#styles = [
{ name: 'Note', class: 'note-block', bg: '#e8f5e9' },
{ name: 'Warning', class: 'warning-block', bg: '#fff3e0' },
{ name: 'Info', class: 'info-block', bg: '#e3f2fd' },
];
/**
* @constructor
* @param {SunEditor.Kernel} kernel - The Kernel instance
*/
constructor(kernel) {
super(kernel);
this.title = 'Quick Style';
this.icon = 'blockStyle';
let html = '';
for (const style of this.#styles) {
html += `<li><button type="button" class="se-btn se-btn-list" data-command="${style.class}"
style="background:${style.bg};padding:4px 8px">${style.name}</button></li>`;
}
const menu = dom.utils.createElement('div', { class: 'se-dropdown se-list-layer' }, `<div class="se-list-inner"><ul class="se-list-basic">${html}</ul></div>`);
this.$.menu.initDropdownTarget(QuickStyle, menu);
}
/**
* @override
* @type {PluginDropdown['action']}
*/
action(target) {
const className = target.getAttribute('data-command');
if (!className) return;
const lines = this.$.format.getLines();
for (const line of lines) {
dom.utils.toggleClass(line, className);
}
this.$.menu.dropdownOff();
this.$.focusManager.focus();
this.$.history.push(false);
}
}
export default QuickStyle;A modal plugin with a controller for editing embedded iframes.
import { interfaces } from 'suneditor';
import type { SunEditor } from 'suneditor/types';
import Modal from 'suneditor/src/modules/contract/Modal';
import Controller from 'suneditor/src/modules/contract/Controller';
import { dom } from 'suneditor/src/helper';
class Embed extends interfaces.PluginModal implements interfaces.ModuleModal, interfaces.ModuleController, interfaces.EditorComponent {
static key = 'embed';
_element: HTMLIFrameElement | null = null;
#isUpdate = false;
modal: InstanceType<typeof Modal>;
controller: InstanceType<typeof Controller>;
urlInput: HTMLInputElement;
static component(node: Node): Node | null {
const el = dom.check.isFigure(node) ? (node as HTMLElement).firstElementChild : node;
return /^IFRAME$/i.test(el?.nodeName ?? '') ? el : null;
}
constructor(kernel: SunEditor.Kernel) {
super(kernel);
this.title = 'Embed';
this.icon = 'embed';
// Modal HTML
const modalEl = dom.utils.createElement(
'div',
null,
`<form>
<div class="se-modal-header">
<button type="button" data-command="close" class="se-btn se-modal-close"></button>
<span class="se-modal-title">Embed URL</span>
</div>
<div class="se-modal-body">
<label>URL</label>
<input class="se-input-form" type="url" placeholder="https://..." />
</div>
<div class="se-modal-footer">
<button type="submit" class="se-btn-primary"><span>Insert</span></button>
</div>
</form>`,
);
// Controller HTML
const controllerEl = dom.utils.createElement(
'div',
{ class: 'se-controller' },
`<div>
<button type="button" data-command="edit" class="se-btn" title="Edit">Edit</button>
<button type="button" data-command="delete" class="se-btn" title="Delete">Delete</button>
</div>`,
);
this.modal = new Modal(this, this.$, modalEl);
this.controller = new Controller(this, this.$, controllerEl);
this.urlInput = modalEl.querySelector('input')!;
}
/** @override PluginModal */
open(): void {
this.modal.open();
}
/** @hook Modules.Modal — Action */
async modalAction(): Promise<boolean> {
const url = this.urlInput.value.trim();
if (!url) return false;
if (this.#isUpdate && this._element) {
this._element.src = url;
} else {
const iframe = dom.utils.createElement('IFRAME', {
src: url,
width: '560',
height: '315',
frameborder: '0',
allowfullscreen: 'true',
}) as HTMLIFrameElement;
this.$.html.insert(iframe.outerHTML);
}
this.$.history.push(false);
return true;
}
/** @hook Modules.Modal — On */
modalOn(isUpdate: boolean): void {
this.#isUpdate = isUpdate;
this.urlInput.value = isUpdate && this._element ? this._element.src : '';
this.urlInput.focus();
}
/** @hook Modules.Modal — Init */
modalInit(): void {
this.controller.close();
}
/** @hook Modules.Modal — Off */
modalOff(): void {
this.urlInput.value = '';
}
/** @hook Modules.Controller — Action */
controllerAction(target: HTMLElement): void {
const command = target.getAttribute('data-command');
if (command === 'edit') {
this.modal.open();
} else if (command === 'delete') {
this.componentDestroy(this._element!);
}
}
/** @hook Editor.Component — Select */
componentSelect(target: HTMLElement): void {
this._element = target as HTMLIFrameElement;
this.controller.open(target, null, { isWWTarget: false });
}
/** @hook Editor.Component — Deselect */
componentDeselect(): void {
this._element = null;
}
/** @hook Editor.Component — Destroy */
async componentDestroy(target: HTMLElement): Promise<void> {
const container = dom.query.getParentElement(target, dom.check.isFigure) || target;
const focusEl = container.previousElementSibling || container.nextElementSibling;
dom.utils.removeItem(container);
this._element = null;
this.$.focusManager.focusEdge(focusEl);
this.$.history.push(false);
}
}
export default Embed;Plugins are registered as class references in options.plugins:
import SUNEDITOR from 'suneditor';
import plugins from 'suneditor/src/plugins';
import MyPlugin from './plugins/myPlugin';
import AnotherPlugin from './plugins/anotherPlugin';
// Array format (recommended)
SUNEDITOR.create('editor', {
plugins: [...plugins, MyPlugin, AnotherPlugin],
buttonList: [['bold', 'italic', 'myPlugin', 'anotherPlugin']],
});
// Object format
SUNEDITOR.create('editor', {
plugins: { ...plugins, myPlugin: MyPlugin, anotherPlugin: AnotherPlugin },
buttonList: [['bold', 'italic', 'myPlugin', 'anotherPlugin']],
});Pass plugin-specific options via options[pluginKey]:
SUNEDITOR.create('editor', {
plugins: [MyPlugin],
buttonList: [['myPlugin']],
myPlugin: {
maxItems: 10,
apiUrl: '/api/data',
},
});These options are passed as the second argument to the constructor: constructor(kernel, pluginOptions).
-
Always pass class references — The kernel manages instantiation and lifecycle.
// Correct plugins: [MyPlugin]; // Wrong — kernel cannot manage lifecycle plugins: [new MyPlugin()];
-
static keymust matchbuttonListname — The toolbar maps button names to plugin keys. -
Plugins without toolbar buttons —
PluginFieldplugins (likemention) don't need to appear inbuttonList. They are registered and respond to editor events automatically.
For studying real-world implementations:
| Plugin | Type | Complexity | File |
|---|---|---|---|
| Blockquote | Command | Simple | src/plugins/command/blockquote.js |
| Align | Dropdown | Simple | src/plugins/dropdown/align.js |
| Font | Dropdown | Medium | src/plugins/dropdown/font.js |
| Link | Modal + Controller | Medium | src/plugins/modal/link.js |
| Image | Modal + Component + FileManager | Complex | src/plugins/modal/image/index.js |
| Video | Modal + Component + FileManager | Complex | src/plugins/modal/video/index.js |
| Table | DropdownFree + Component + Controller | Complex | src/plugins/dropdown/table/index.js |
| Mention | Field | Medium | src/plugins/field/mention.js |
| FontSize | Input | Simple | src/plugins/input/fontSize.js |