Loading...
Loading...
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:
contentEditable behavior.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.
$ (Deps) ObjectInstead 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
}
contextProvider, optionProvider). These are added to the Deps bag ($) immediately.
selection, history, toolbar), then assigns them to the Deps bag ($).
$ after they are all created._init() on each L3 instance that implements it.
EventOrchestrator is 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 flagComponents 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(); // cleanup
SunEditor 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.
format.isLine(element) - checks against formatLine regexP, H[1-6], LI, TH, TD, DETAILS, PREformat.isNormalLine()): Standard text containers - P, DIV, H1-H6, LI, DETAILS
<p>Line 1</p><p>Line 2</p>format.isBrLine()): Line breaks use <BR> tags - PRE
<BR> within same element<pre>Line 1<br>Line 2</pre>format.isClosureBrLine()): BR lines that cannot be exited with Enter/Backspace
format.isBlock(element) - checks against formatBlock regexBLOCKQUOTE, OL, UL, FIGCAPTION, TABLE, THEAD, TBODY, TR, CAPTION, DETAILS<blockquote><p>quoted text</p></blockquote>)format.isBlock() but not closure): Standard structural containers
format.isClosureBlock()): Constrained blocks that trap cursor
TH, TD (table cells)Special Case: Lists (OL/UL/LI)
Lists are a special Block-Line combination where:
OL, UL): Block-level elementsLI): Line-level elements that can ONLY exist inside list containerslistFormat.js handles list-specific operations (nesting, indentation, merging)dom.check.isList(), dom.check.isListCell()component.is(element) - checks for component pluginsse-component or se-flex-component at the top level
<div class="se-component"><figure><img|iframe|video></figure></div><figure class="se-flex-component"><table>...</table></figure><div class="se-component se-flex-component"><figure><audio|a></figure></div>component.isInline(element) - checks for se-inline-component class<span class="se-component se-inline-component"> wrapperformat.isLine/isBlock/isClosureBlock and component.is/isInline during editing operationsblock → contains → line (structural containment)line exist at the same hierarchy levelline<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
store.get('rootKey') === null) and multi-root use the same architecture$.context): Shared UI elements (toolbar, statusbar, modal overlay)$.frameContext and $.frameOptions are pointers, updated by changeFrameContext(rootKey)editor.changeFrameContext(rootKey) updates store.rootKey and resets pointersPlugins 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.
An in-content component (image, table, pageBreak, …) reaches component.select() through one of two paths:
static component(node) + the EditorComponent hooks; owns any controller/actions.SunEditor.ComponentLauncher) pushed to pluginManager's component-checkers, for components with no UI beyond delete (e.g. pageBreak). It carries the same hook names a plugin would (componentDestroy/componentSelect/componentDeselect), since select() stores it as currentPlugin. Mis-naming a hook silently breaks the component (bug #1670); the ComponentLauncher typedef enforces the names at build time.See guide/custom-plugin.md → "Non-plugin components (the launcher path)" for the full contract.
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 from false)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.