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'; import { dom } from '../../helper'; class Blockquote extends PluginCommand { static key = 'blockquote'; constructor(kernel) { super(kernel); // KernelInjector → this.$ = kernel.$ (Deps bag) this.title = this.$.lang.tag_blockquote; } action() { this.$.format.applyBlock(dom.utils.createElement('BLOCKQUOTE')); } }
Example - Core Logic Class:
class Component { #kernel; #$; #store; #options; #frameContext; #eventManager; 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) |
Runtime import boundaries below are checked by dependency-cruiser (.dependency-cruiser.js). npm run test:arch verifies accepted and rejected fixture graphs with the real checker. Runtime property access and every L1–L4 lifecycle invariant are still review responsibilities.
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 | No cross-plugin imports or public-barrel bypass (same plugin submodules OK); no direct core/logic imports |
| L3 isolation | No direct imports between services, except the exact existing ownership edges below |
| No circular deps | No module can import from a module that imports it |
Existing exact exceptions: commandDispatcher → _commandExecutor (owned executor),
ui → blockHandle (owned UI), blockHandle → blockResolver (owned helper), and
ui → commandDispatcher (COMMAND_BUTTONS constant). An exception permits only that
file pair; it does not allow the owner to import arbitrary services. The checker operates
on file edges, so reviewers must ensure the last edge still imports only the constant.
Do not broaden exceptions to silence a new dependency violation; use $ for service access.
Type-only documentation imports are outside this runtime graph (tsPreCompilationDeps: false).
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 this.$.html.insert('<p>Example</p>'); // insertion owns history } }
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) |
The diagram below describes hook/effect dispatch, not the complete timing of every DOM handler. Public and plugin hooks may cancel subsequent processing with false. In the keydown handler, guards, normalization, shortcuts and action reduction occur before these hooks, and the reduced prevent/stop actions are applied at once (applyEventControl) — a preventDefault() issued after an await can land after the browser's default action. The executor follows the hooks; a hook that cancels skips it, and a key already prevented stays prevented. The Enter beforeinput route has its own synchronous cancellation path and does not pass through the generic hooks. Read the actual handler before moving any work across an await.
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:
// Guards, normalization, shortcuts and reduceKeydown already ran. applyEventControl(actions, event); // prevent/stop now, before the first await // 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: Execute the previously reduced actions
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.