Loading...
Loading...
Technical reference for creating custom SunEditor plugins. Covers all plugin types, hooks, modules, and patterns for both JavaScript and TypeScript.
this.$)All plugins extend a base class from src/interfaces/plugins.js. The inheritance chain:
KernelInjector → Base → PluginCommand / PluginModal / PluginDropdown / ...
KernelInjector — Receives the Kernel and exposes this.$ (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).options.plugins. The Kernel instantiates them.this.$ (the Deps bag), never import core modules directly.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. |
static optionsclass 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) }; }
static component(node)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 to super() to inject this.$ (Deps bag).pluginOptions (object) — Plugin-specific options from options[pluginKey]. Define a @typedef for type checking.this.$)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:
@hook + @type — Hook MethodsUsed 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 |
@override + @type — Base Class Method OverridesUsed when overriding required/optional methods from the base plugin class:
/** * @override * @type {PluginModal['open']} */ open(target) { ... } /** * @override * @type {PluginInput['toolbarInputKeyDown']} */ toolbarInputKeyDown({ target, event }) { ... }
@imple + @type — Cross-Plugin Interface MethodsUsed 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 |
EditorComponent Interface)Source: src/interfaces/contracts.js
For plugins that create static components in the editor (e.g., images, videos, tables, embeds).
Requirements:
static component(node) — Returns the component element if the node belongs to this plugin, or null._element property — 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
Not every selectable component is a full plugin. Lightweight components with no UI beyond
delete — e.g. the built-in pageBreak — are registered directly as a component-checker
in pluginManager, returning { target, launcher } instead of { target, pluginName, options }:
// src/core/logic/shell/pluginManager.js this.#componentCheckers.push((element) => { if (!element || !dom.utils.hasClass(element, 'se-page-break')) return null; /** @type {SunEditor.ComponentLauncher} */ const launcher = { componentDestroy: (target) => { /* remove + refocus + history.push */ }, }; return { target: element, launcher }; });
On selection, component.select() assigns currentPlugin = info.launcher || plugins[pluginName],
so a launcher is a stand-in for a plugin. The selected-component keydown handler then calls
currentPlugin.componentDestroy(target).
Hook-name contract. A launcher must use the same hook names a plugin would —
componentDestroy/componentSelect/componentDeselect. TheSunEditor.ComponentLaunchertypedef (src/typedef.js) enforces the names at build time via the excess-property check — annotate the launcher as a typedconst(an assignment, not a cast).Full plugin vs launcher: promote to a full plugin only when the component needs a controller or actions beyond delete; a controller can only be owned by a full plugin instance, never a launcher.
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.
extends vs implementsA 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 |
extends — Plugin Base Type (single inheritance)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())
implements — Module ContractsPlugins 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()implements — Cross-Plugin-Type CompositionA 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 bagA 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 key must match buttonList name — The toolbar maps button names to plugin keys.
Plugins without toolbar buttons — PluginField plugins (like mention) don't need to appear in buttonList. 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 |