Note: This document describes SunEditor internals in detail. For usage guides and API references, please see GUIDE.md.
SunEditor is architected to be a WYSIWYG editor with constraints:
- Zero Dependencies: No frameworks (React/Vue/Angular) or libraries (jQuery/Lodash) in the core.
- Vanilla JavaScript: Written in modern ES2022+, utilizing native browser APIs.
- State-aware editing: Uses internal state management in addition to native
contentEditablebehavior.
The system follows a layered architecture with a central Dependency Injection (DI) container.
graph TD
User[User Code] --> Factory[suneditor.js]
Factory --> Facade[editor.js]
Facade --> Kernel[CoreKernel - L1]
subgraph Core Kernel
Kernel --> Store[Store - State]
Kernel --> Config[L2 Config Providers]
Kernel --> Logic[L3 Business Logic]
Kernel --> Event[L4 Event Orchestrator]
end
Logic --> DOM[dom - Selection, Format ...]
Logic --> Shell[shell - Component, History ...]
Logic --> Panel[panel - Toolbar, Menu, Viewer]
Event --> Handlers
Event --> Reducers
Event --> Rules
Event --> Executor[Actions - Executor]
Event --> Effects
┌─────────────────────────────────────────────────────────────────┐
│ suneditor.js │
│ (Factory Entry Point) │
│ • create(target, options) → new Editor() │
│ • init(options) → { create() } │
└────────────────────────────┬────────────────────────────────────┘
│ creates
▼
┌─────────────────────────────────────────────────────────────────┐
│ editor.js │
│ (Main Editor Class - Facade) │
│ │
│ Public: isEmpty, resetOptions, changeFrameContext, destroy │
│ Internal: Plugin lifecycle, multi-root, frame init │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ CoreKernel (L1) │ │
│ │ Dependency Container & Orchestrator │ │
│ │ │ │
│ │ ┌──────────┐ ┌────────────────────────────────────┐ │ │
│ │ │ Store │ │ $ (Deps bag) │ │ │
│ │ │ #state │ │ All dependencies in one object │ │ │
│ │ │ mode │ │ Shared with all consumers │ │ │
│ │ └──────────┘ └────────────────────────────────────┘ │ │
│ │ │ │
│ │ L2: Config ───────────────────────────────────────────┐ │ │
│ │ │ contextProvider │ optionProvider │ │ │
│ │ │ instanceCheck │ eventManager │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ L3: Logic ────────────────────────────────────────────┐ │ │
│ │ │ dom/: selection, format, inline, html │ │ │
│ │ │ listFormat, nodeTransform, char, offset │ │ │
│ │ │ shell/: component, focusManager, pluginManager │ │ │
│ │ │ ui, commandDispatcher, history, shortcuts │ │ │
│ │ │ panel/: toolbar, subToolbar*, menu, viewer │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ L4: Event ────────────────────────────────────────────┐ │ │
│ │ │ EventOrchestrator │ │ │
│ │ │ (handlers → reducers → rules → executor → effects) │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
suneditor.js: The public entry point. It validates initialization options and target elements before creating the editor instance.editor.js: The Facade. It orchestrates initialization, plugin lifecycle, and multi-root management. Exposes minimal public methods (isEmpty,resetOptions,changeFrameContext,destroy) and the$(Deps) object for full API access.
The central runtime container is CoreKernel (src/core/kernel/coreKernel.js).
Terminology: The Kernel (
CoreKernel) is the runtime container that orchestrates initialization and lifecycle. The$(Deps bag) is the shared dependency object that the Kernel builds and provides — it is not the Kernel itself. The Store manages runtime state.
Instead of passing dozens of arguments between modules, the Kernel builds a single dependency bag called $ (Deps). This object is shared by reference across the entire system. $ is the dependency context provided by the Kernel, not the Kernel itself.
Full $ Object Structure:
$ = {
// L1: Core
facade, // Editor instance (public API)
store, // Store instance
// L2: Config (Phase 1 - available to L3 constructors)
contextProvider, // Context/FrameContext management
optionProvider, // Options/FrameOptions management
instanceCheck, // Iframe-safe type checks
eventManager, // Public event API
// L2: Convenience accessors
frameRoots, // Map<rootKey, FrameContext>
context, // Global context (toolbar, statusbar, etc.)
options, // Base options Map
icons, // Icon set
lang, // Language strings
frameContext, // Current frame context (pointer)
frameOptions, // Current frame options (pointer)
// L3: Logic (Phase 2 - added after all L3 instances created)
// dom/
offset, selection, format, inline,
listFormat, html, nodeTransform, char,
// shell/
component, focusManager, pluginManager, plugins,
ui, commandDispatcher, history, shortcuts,
// panel/
toolbar, subToolbar, menu, viewer // subToolbar: second Toolbar instance, only if _subMode is set
}
- Phase 1 (Config): The Kernel initializes L2 providers (
contextProvider,optionProvider). These are added to the Deps bag ($) immediately.- Why? L3 Logic classes need these configs during their own construction.
- Phase 2 (Logic): The Kernel initializes L3 Logic modules (
selection,history,toolbar), then assigns them to the Deps bag ($).- Why? Events and circular dependencies are resolved by assigning these instances to
$after they are all created.
- Why? Events and circular dependencies are resolved by assigning these instances to
- Init Pass: After Phase 2, the Kernel runs
_init()on each L3 instance that implements it.- Why? Some Logic modules require references to other L3 modules (which only become available after Phase 2).
- L4 (Event): Finally,
EventOrchestratoris created, completing the initialization chain.
| Consumer | Constructor | Access Pattern |
|---|---|---|
| Plugin | constructor(kernel, pluginOptions?) |
this.$ (Deps bag) via KernelInjector |
| Core Logic (L3) | constructor(kernel) |
#kernel (Kernel), #$ (Deps bag), #store (Store) |
| Module | constructor(inst, $, ...) |
#$ (Deps bag passed directly) |
| EventOrchestrator (L4) | constructor(kernel) |
this.$ (Deps bag) via KernelInjector |
Example - Plugin:
import { PluginCommand } from '../../interfaces';
class Blockquote extends PluginCommand {
static key = 'blockquote';
constructor(kernel) {
super(kernel); // KernelInjector → this.$ = kernel.$ (Deps bag)
this.title = this.$.lang.tag_blockquote;
}
action() {
const node = this.$.selection.getNode();
this.$.format.applyBlock(this.quoteTag.cloneNode(false));
}
}Example - Core Logic Class:
class Component {
#kernel;
#$;
#store;
constructor(kernel) {
this.#kernel = kernel; // Kernel (runtime container)
this.#$ = kernel.$; // Deps bag (shared dependency object)
this.#store = kernel.store; // Store (runtime state)
// Cache frequently used services from Deps
this.#options = this.#$.options;
this.#frameContext = this.#$.frameContext;
this.#eventManager = this.#$.eventManager;
}
}Example - Module:
class Modal {
#$;
constructor(inst, $, element) {
this.#$ = $; // Deps bag passed directly, no inheritance
this.inst = inst;
this.#$.eventManager.addEvent(element, 'submit', ...);
}
}| Layer | Responsibility | Components |
|---|---|---|
| L1: Kernel | Dependency Injection & State | CoreKernel, Store, KernelInjector |
| L2: Config | Environment & Options | ContextProvider, OptionProvider, InstanceCheck, EventManager |
| L3: Logic | Core editing logic | Selection, Format, History, PluginManager, Toolbar, Component |
| L4: Event | Input Orchestration | EventOrchestrator (Redux-style event pipeline) |
Dependency boundaries are enforced at build time via dependency-cruiser (.dependency-cruiser.js).
Allowed dependency direction:
L1 (Kernel) ─── orchestrates all layers
↓
L2 (Config) ─── available to L3/L4 via Deps ($)
↓
L3 (Logic) ─── cross-references via Deps ($) only (no direct imports between L3 modules)
↓
L4 (Event) ─── accesses L2/L3 via Deps ($)
↓
Helper ─── pure utilities, all layers can import
Enforced constraints:
| Rule | Description |
|---|---|
| Helper isolation | helper/* cannot import from any other layer |
| Module isolation | modules/* cannot import core/* or plugins/* — receives Deps ($) via constructor |
| Plugin isolation | Plugins cannot import other plugins (same plugin submodules OK) |
| No circular deps | No module can import from a module that imports it |
Circular dependency resolution: L3 modules that need each other (e.g., format ↔ selection) don't import directly. Both receive the full Deps bag ($) after Phase 2, resolving circular references at runtime.
State is managed by Store (src/core/kernel/store.js). It distinguishes clearly between Configuration (read-only options) and Runtime State (mutable).
| Key | Type | Default | Description |
|---|---|---|---|
rootKey |
* |
product.rootId |
Current root frame key |
hasFocus |
boolean |
false |
Whether the editor has focus |
tabSize |
number |
4 |
Tab character space count |
indentSize |
number |
25 |
Block indent margin (px) |
codeIndentSize |
number |
2 |
Code view indent space count |
currentNodes |
string[] |
[] |
Selection path tag names |
currentNodesMap |
string[] |
[] |
Active command/style names |
initViewportHeight |
number |
0 |
Viewport height at init |
currentViewportHeight |
number |
0 |
Current visual viewport height |
controlActive |
boolean |
false |
Controller/component active |
isScrollable |
function |
(fc) => ... |
Frame content scrollability |
_lastSelectionNode |
?Node |
null |
Last selection node (cache) |
_range |
?Range |
null |
Cached selection range |
_mousedown |
boolean |
false |
Mouse button pressed |
_preventBlur |
boolean |
false |
Suppress blur handling |
_preventFocus |
boolean |
false |
Suppress focus handling |
store.mode- Immutable toolbar mode flags (isClassic,isInline,isBalloon,isBalloonAlways,isSubBalloon,isSubBalloonAlways)store._editorInitFinished- Editor initialization complete flag
Components subscribe to state changes to update UI reactively without tight coupling.
// Read
const rootKey = store.get('rootKey');
const hasFocus = store.get('hasFocus');
// Write (notifies subscribers)
store.set('hasFocus', true);
store.set('_preventBlur', false);
// Subscribe
const unsubscribe = store.subscribe('hasFocus', (newVal, oldVal) => { ... });
unsubscribe(); // cleanupSunEditor uses JSDoc for type annotations and TypeScript for type checking (no TS source files, only generated .d.ts).
| JSDoc Type | Meaning | Used For |
|---|---|---|
SunEditor.Kernel |
Kernel (CoreKernel) — runtime container | Constructor @param in L3/L4 classes |
SunEditor.Deps |
Deps bag ($) — shared dependency object, NOT the Kernel |
this.$ type, event callback params |
SunEditor.Store |
Store — runtime state management | kernel.store, this.#store |
SunEditor.Instance |
Editor class | Public API facade |
SunEditor.Context |
ContextMap | Global context (toolbar, statusbar) |
SunEditor.FrameContext |
FrameContextMap | Per-frame context |
SunEditor.Options |
BaseOptionsMap | Shared options |
SunEditor.FrameOptions |
FrameOptionsMap | Per-frame options |
Rule: SunEditor.Kernel is used ONLY for constructor parameter types (the Kernel instance). For everything else (event params, plugin this.$, module deps), use SunEditor.Deps (the Deps bag).
SunEditor uses explicit content rules to reduce inconsistent contentEditable output.
Editor operations classify nodes using these categories: Line, Block, Component, and Inline Component.
- Definition: Basic text container elements that hold inline content and text
- Purpose: Contains inline content, text, and inline formatting (bold, italic, etc.)
- Validation:
format.isLine(element)- checks againstformatLineregex - Default Tags:
P,H[1-6],LI,TH,TD,DETAILS,PRE - Subtypes:
- Normal Line (
format.isNormalLine()): Standard text containers -P,DIV,H1-H6,LI,DETAILS- Line breaks: Use Enter key to create new line elements
- Example:
<p>Line 1</p><p>Line 2</p>
- BR Line (
format.isBrLine()): Line breaks use<BR>tags -PRE- Line breaks: Enter creates
<BR>within same element - Example:
<pre>Line 1<br>Line 2</pre>
- Line breaks: Enter creates
- Closure BR Line (
format.isClosureBrLine()): BR lines that cannot be exited with Enter/Backspace- Used for special constrained editing contexts (e.g., table cells with BR mode)
- Normal Line (
- Definition: Structural container elements that wrap lines
- Purpose: Provides structural hierarchy around editable content
- Validation:
format.isBlock(element)- checks againstformatBlockregex - Default Tags:
BLOCKQUOTE,OL,UL,FIGCAPTION,TABLE,THEAD,TBODY,TR,CAPTION,DETAILS - Relationship: Blocks structurally contain lines (e.g.,
<blockquote><p>quoted text</p></blockquote>) - Subtypes:
- Normal Block (
format.isBlock()but not closure): Standard structural containers- Can be exited: Pressing Enter/Backspace at edges exits the block
- Closure Block (
format.isClosureBlock()): Constrained blocks that trap cursor- Tags:
TH,TD(table cells) - Cannot be exited: Enter/Backspace always stays within the block
- Tags:
- Normal Block (
Special Case: Lists (OL/UL/LI)
Lists are a special Block-Line combination where:
- List Container (
OL,UL): Block-level elements - List Item (
LI): Line-level elements that can ONLY exist inside list containers - Dedicated Class:
listFormat.jshandles list-specific operations (nesting, indentation, merging) - Common Checks:
dom.check.isList(),dom.check.isListCell()
- Definition: Self-contained interactive elements (images, videos, tables, embedded content)
- Purpose: Rich media and special features - same level as line (not contained in line)
- Validation:
component.is(element)- checks for component plugins - Container: Component plugins typically use
se-componentorse-flex-componentat the top level- Images, Videos:
<div class="se-component"><figure><img|iframe|video></figure></div> - Tables:
<figure class="se-flex-component"><table>...</table></figure> - Audio, File uploads:
<div class="se-component se-flex-component"><figure><audio|a></figure></div>
- Images, Videos:
- Examples: Images, videos, audio, tables, drawings
- Definition: Components that exist inside lines (exception to the component-line sibling rule)
- Validation:
component.isInline(element)- checks forse-inline-componentclass - Container: Uses
<span class="se-component se-inline-component">wrapper - Examples: Math formulas, inline anchors
- Category checks: Core logic uses
format.isLine/isBlock/isClosureBlockandcomponent.is/isInlineduring editing operations - Hierarchy:
block→ contains →line(structural containment) - Siblings: Block components and
lineexist at the same hierarchy level - Inline Exception: Inline components can exist inside a
line - Block Wrapping: Blocks provide structure by wrapping multiple lines
<div class="se-wrapper-wysiwyg">
<p>
Line 1: text with <span class="se-component se-inline-component"><katex>E=mc^2</katex></span> formula
</p>
<blockquote>
<p>Line 2: quoted text</p>
</blockquote>
<div class="se-component">
<figure><img src="..." /></figure>
</div>
<ul>
<li>Line 3: list item</li>
<li>Line 4: list item</li>
</ul>
</div>The strictMode option controls how strictly SunEditor validates and cleans HTML content.
Configuration:
SUNEDITOR.create('#editor', {
// Enable all filters (default)
strictMode: true,
// Granular control
strictMode: {
tagFilter: true,
formatFilter: true,
classFilter: true,
textStyleTagFilter: true,
attrFilter: true,
styleFilter: true,
},
});| Filter | Purpose | When Disabled |
|---|---|---|
tagFilter |
Removes disallowed HTML tags | Allows any tags (security risk) |
formatFilter |
Enforces line/block/component structure | Components may not wrap properly |
classFilter |
Validates CSS classes | Allows any CSS classes |
textStyleTagFilter |
Converts <B>, <I>, <U> to styled <SPAN> |
Keeps original tags |
attrFilter |
Filters attributes | Allows any attributes (XSS risk) |
styleFilter |
Filters inline styles | Allows any inline styles |
SunEditor uses a unified frame architecture for both single and multi-root editing.
editor
├── $.frameRoots (Map<rootKey, FrameContext>) ← Actual data storage
│ ├── null → FrameContext [Single-root: rootKey is null]
│ ├── rootKey1 → FrameContext1 [Multi-root]
│ └── rootKey2 → FrameContext2
│
├── $.context (ContextMap) ← Global shared UI
├── $.frameContext (FrameContextMap) ← Current frame pointer
├── $.frameOptions (FrameOptionsMap) ← Current frame options pointer
└── $.options (BaseOptionsMap) ← Shared config
- Unified Structure: Single-root (
store.get('rootKey') === null) and multi-root use the same architecture - frameRoots Map: Actual storage of all frame contexts
- Global Context (
$.context): Shared UI elements (toolbar, statusbar, modal overlay) - Current Frame References:
$.frameContextand$.frameOptionsare pointers, updated bychangeFrameContext(rootKey) - Frame switching:
editor.changeFrameContext(rootKey)updatesstore.rootKeyand resets pointers
Plugins follow the same integration pattern as core consumers. They extend KernelInjector, giving them direct access to the core $ object.
class MyPlugin extends PluginCommand {
constructor(kernel) {
super(kernel); // KernelInjector → this.$ = kernel.$ (Deps bag)
}
action() {
// Access core logic directly
const selection = this.$.selection.get();
this.$.history.push();
}
}Because plugins extend KernelInjector, they receive this.$ — the same Deps bag that core modules use. Note that $ is the dependency context provided by the Kernel, not the Kernel itself.
The event system is split into two distinct classes:
| EventManager (L2) | EventOrchestrator (L4) | |
|---|---|---|
| Location | config/eventManager.js |
event/eventOrchestrator.js |
| Role | Public event registration API | Internal DOM event processing |
| Methods | addEvent, removeEvent, addGlobalEvent, removeGlobalEvent, triggerEvent |
_addCommonEvents, _addFrameEvents, applyTagEffect, _callPluginEvent |
| Used by | Plugins, modules, core logic | CoreKernel only (internal) |
| Extends | None | KernelInjector |
EventManager is available as this.$.eventManager throughout the system. EventOrchestrator is created by CoreKernel and manages the internal event pipeline.
To handle the complexity of cross-browser contentEditable events, SunEditor uses a Redux-like pipeline:
DOM Event → Handler → Reducer → Rules → Action[] → Executor → Effects → DOM Update
| Component | Location | Purpose |
|---|---|---|
| Handlers | event/handlers/ |
Capture raw DOM events (key, mouse, input, clipboard, dragDrop) |
| Reducers | event/reducers/ |
Analyze event + state → produce action list |
| Rules | event/rules/ |
Granular key behavior rules (enter, backspace, delete, arrow, tab) |
| Actions | event/actions/ |
Action type definitions ({ t: string, p?: * }) |
| Executor | event/executor.js |
Dispatches action list through effect registries |
| Effects | event/effects/ |
Side-effect handlers (common.registry, keydown.registry, ruleHelpers) |
Wysiwyg DOM events pass through three stages in order. Each stage can cancel further processing by returning false.
DOM Event (keydown, input, click, paste, ...)
↓
┌─────────────────────────────────────┐
│ Stage 1: Public Event │
│ eventManager.triggerEvent('onXxx') │
│ → user-registered callback │
│ → return false = cancel │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ Stage 2: Plugin Event │
│ pluginManager.emitEventAsync('onXxx')│
│ → each plugin's onXxx() hook │
│ → sorted by eventIndex │
│ → first boolean return stops loop │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ Stage 3: Core Processing │
│ Reducer → Rules → Action[] │
│ → Executor → Effects → DOM Update │
│ → History Push │
│ → triggerEvent('onChange') │
└─────────────────────────────────────┘
Example — onKeyDown in handler_ww_key.js:
// Stage 1: User event
if ((await this.$.eventManager.triggerEvent('onKeyDown', { frameContext, event })) === false) return;
// Stage 2: Plugin event
if ((await this._callPluginEventAsync('onKeyDown', { frameContext, event, range, line })) === false) return;
// Stage 3: Core processing (reducer → actions → effects)Toolbar clicks bypass the 3-stage pipeline and dispatch directly to plugins via CommandDispatcher:
OnClick_toolbar(e)
↓
commandDispatcher.runFromTarget(button)
↓
Extract: data-command, data-type
↓
Branch by type:
├─ "command" → plugins[cmd].action(button)
├─ "dropdown" → menu.dropdownOn(button) → plugin.on() → item click → plugin.action()
├─ "dropdown-free"→ menu.dropdownOn(button) → plugin.on() → plugin handles own events
├─ "modal" → plugins[cmd].open(button)
├─ "browser" → plugins[cmd].open(null)
├─ "popup" → plugins[cmd].show()
└─ (built-in) → CommandExecutor.execute(cmd) (bold, undo, etc.)
EventManager.triggerEvent(name, data) dispatches events to user-registered handlers (from options.events).
// Internal implementation
triggerEvent = async (eventName, eventData) => {
const handler = this.events[eventName];
if (typeof handler === 'function') {
return await handler({ $: this.#$, ...eventData });
}
return NO_EVENT; // no handler registered
};Return values:
false— Handler explicitly canceled the actiontrue/ truthy — Handler processed, continue normallyNO_EVENT— No handler registered (distinct fromfalse)
1. Wysiwyg Input (typing, paste, etc.):
DOM Event → Handler
├─→ Stage 1: triggerEvent('onXxx') [user callback]
├─→ Stage 2: emitEventAsync('onXxx') [plugin hooks]
└─→ Stage 3: Reducer → Rules → Action[] → Executor → Effects
↓
DOM Update → History Push → onChange
2. Toolbar Click:
Button Click → CommandDispatcher.run(cmd, type)
├─→ plugin.action() / plugin.open() / plugin.show()
└─→ (for built-ins) → CommandExecutor → inline/format logic
↓
DOM Update → History Push → onChange
3. Selection Change:
selectionchange → EventOrchestrator.applyTagEffect(node)
├─→ selectionState.update() [track current node path]
└─→ plugin.active(element, button) for each active command
↓
Toolbar button state updated
This separation keeps event handling predictable and easier to maintain across browsers.