Coding Rules
June 3, 2026 Β· View on GitHub
This document defines the enforceable coding conventions for SunEditor source code.
Read this before writing or modifying any .js file under src/.
- What this is: rules about which APIs to call when writing code.
- What this is not: file-level edit restrictions (see
editing-rules.md), architecture overview (seeARCHITECTURE.md), or commit conventions (seeguide/commit-types.md).
For each rule: β
canonical pattern, β anti-pattern, π‘ why.
Most violations are caught by dependency-cruiser or break at runtime in iframe mode β but not all. Treat this as a checklist, not a suggestion.
0. The $ access rule
Every consumer (plugin, L3 module, L4 orchestrator, module) accesses dependencies through the Deps bag ($).
| Consumer | How $ is obtained |
|---|---|
| Plugin | super(kernel) β this.$ auto-injected by KernelInjector |
| L3 / L4 | constructor(kernel) β store kernel.$ in #$ field |
| Module | constructor(host, $, element, ...) β $ passed directly |
β
Always go through $. Never import other L3 modules directly.
β Never reach into kernel itself except for kernel.$ and kernel.store in core constructors.
Background:
ARCHITECTURE.mdΒ§3.
1. Events β eventManager only
All DOM listeners must go through this.$.eventManager. Raw addEventListener is forbidden in plugin and module code.
β DO
// Element-bound listener β auto-tracked, auto-removed on destroy/setOptions
const info = this.$.eventManager.addEvent(element, 'click', this.onClick.bind(this));
// Window/document-level listener β must be tracked manually
this.__esc = this.$.eventManager.addGlobalEvent('keydown', this.onEsc);
// Cleanup (only required for addGlobalEvent β addEvent is automatic)
this.__esc &&= this.$.eventManager.removeGlobalEvent(this.__esc);
β DON'T
element.addEventListener('click', handler); // not tracked, leaks on destroy
window.addEventListener('keydown', handler); // wrong window in iframe mode
document.addEventListener('selectionchange', handler); // bypasses iframe-aware routing
π‘ eventManager.addEvent records every listener in an internal array and removes them all in _init() on destroy/setOptions (src/core/config/eventManager.js:187-196). addGlobalEvent is iframe-aware: when iframe option is on, it registers on the iframe's window too (eventManager.js:138-148).
Public event hooks (triggerEvent)
triggerEvent calls the user-registered events.onXxx handler. It is async β always await.
const result = await this.$.eventManager.triggerEvent('onChange', { frameContext, data });
if (result === false) return; // user canceled
β Calling without await returns a Promise β comparing Promise === false is always false, silently dropping user cancels.
2. DOM mutation β go through $.html / $.format / $.inline
Direct DOM mutation inside the wysiwyg root skips sanitization, history, and char-count. Use the L3 logic layer instead.
β DO
this.$.html.set('<p>new</p>'); // replace, auto history.push(false)
this.$.html.insert('<strong>x</strong>', { selectInserted: true }); // insert at cursor
this.$.html.insertNode(node, { afterNode, skipCharCount: false }); // insert raw node
this.$.format.setLine(pElement); // wrap selection as line
this.$.format.applyBlock(blockquoteEl.cloneNode(false)); // apply block format
this.$.inline.apply(spanFormat, styleArray, true); // apply inline style
Wrappers that already push history (do NOT call history.push again after these):
| Wrapper | Auto-pushes |
|---|---|
$.html.set / $.html.insert / $.html.insertNode | β
push(false) |
$.format.setLine / $.format.applyBlock | β |
$.inline.apply | β |
$.component.insert / $.component.select / $.component.deselect | β |
β DON'T
wysiwygFrame.innerHTML = html; // no sanitization, no history, no char-count
container.appendChild(node); // bypasses format normalization
document.execCommand('bold', false); // deprecated, inconsistent across browsers
π‘ $.html.set also handles rootKey for multi-root frames (src/core/logic/dom/html.js:1322-1344). Skipping it desyncs the history stack for that frame.
3. State β store for runtime, options/context are different things
Four distinct stores, not interchangeable:
| What you want | Use |
|---|---|
| Mutable runtime state (focus, range cache, etc.) | this.$.store.get/set('key') |
| Global UI element (toolbar, statusbar) | this.$.context.get('key') |
Per-frame DOM (wysiwyg root, _ww) | this.$.frameContext.get('key') |
| Shared editor options | this.$.options.get('key') |
| Per-frame options | this.$.frameOptions.get('key') |
β DO
const hasFocus = this.$.store.get('hasFocus');
this.$.store.set('controlActive', true);
const unsub = this.$.store.subscribe('rootKey', (next, prev) => { ... });
// store the unsubscribe; call it on destroy
const wysiwyg = this.$.frameContext.get('wysiwyg');
const isIframe = this.$.frameOptions.get('iframe');
const toolbar = this.$.context.get('toolbar_main');
β DON'T
this.$.store._range = newRange; // bypasses subscribers
this.$.frameContext.get('toolbar_main'); // toolbar is in `context`, not frame
this.$.options.get('iframe'); // iframe is a frame option
this.$.frameRoots.get(rootKey).wysiwyg; // reach through frameContext instead
π‘ Underscored keys (_range, _lastSelectionNode, _preventBlur) are still valid store.set keys β but always go through set() so subscribers fire.
4. Selection & range
Three ways to get a range. Pick the right one:
| Goal | Call |
|---|---|
| Read cached range (cheap, most cases) | this.$.store.get('_range') |
| Read live range from current selection | this.$.selection.getRange() |
| Get range and ensure caret is inside a line element | this.$.selection.getRangeAndAddLine(range, container) |
After mutating selection, restore focus:
this.$.selection.setRange(startNode, sOff, endNode, eOff);
this.$.focusManager.focus();
β window.getSelection() is wrong in iframe mode β it returns the parent window's selection. Use this.$.selection.get() (which routes through frameContext._ww).
5. iframe safety β never instanceof
iframe mode means objects live in a different realm; instanceof HTMLElement returns false for elements inside the iframe.
β DO
import { _w, _d } from '../../helper/env'; // window/document references
if (dom.check.isElement(node)) { ... }
if (dom.check.isText(node)) { ... }
if (dom.check.isImage(node)) { ... }
if (this.$.instanceCheck.isNode(obj)) { ... }
if (this.$.instanceCheck.isRange(obj)) { ... }
const iframeWin = this.$.frameContext.get('_ww');
const iframeDoc = this.$.frameContext.get('_wd');
β DON'T
if (el instanceof HTMLElement) { ... } // false for iframe-side elements
if (obj instanceof Range) { ... } // wrong realm
window.getComputedStyle(el); // use _w or frame's window
document.createElement('div'); // use _d
π‘ The dom.check.* helpers use nodeType and Object.prototype.toString.call(x) internally β both cross-realm safe.
6. History & onChange
history.push(delay, rootKey?) is what triggers the public onChange event.
| Call | Behavior |
|---|---|
this.$.history.push(false) | Debounced save (~400ms), batches rapid edits |
this.$.history.push(true) | Immediate save (use after discrete actions: mouse up, dialog confirm) |
this.$.history.push(false, rootKey) | Multi-root: push to a specific frame |
Rules:
- Any UI handler that mutates persisted wysiwyg DOM must end its chain with
history.push. It's what firesonChange. - If you called a wrapper from Β§2 that already pushes, do not push again β duplicate stack entries break undo.
- Read-only operations must not push. Selection probes, hover effects, controller positioning β no push.
- Never call
history.pushfrom inside anonChangehandler. It re-firesonChangeβ infinite loop.
7. DOM helpers β dom.utils / dom.query / dom.check
Import once at the top of the file:
import { dom, numbers, unicode, converter, env, keyCodeMap } from '../../helper';
| Group | Use for |
|---|---|
dom.utils | createElement, addClass/removeClass/hasClass, getStyle/setStyle, changeElement, getAttributesToString |
dom.query | getParentElement(node, tagOrFn), getNodePath, getNodeFromPath, getListChildren, getEventTarget |
dom.check | isText, isElement, isList, isListCell, isTable, isTableCell, isFigure, isAnchor, isImage, isNonEditable, isEdgePoint |
β DON'T
element.classList.add('active'); // use dom.utils.addClass
element.style.color = 'red'; // use dom.utils.setStyle
const tag = node.parentNode.parentNode...; // use dom.query.getParentElement
node.nodeType === 1; // use dom.check.isElement
8. Imports & layer boundaries
Enforced by .dependency-cruiser.js:
| Rule |
|---|
helper/* cannot import from core/*, modules/*, or plugins/* |
modules/* cannot import from core/* or plugins/* β receives $ via constructor |
L3 modules cannot import other L3 modules directly β cross-reference via $ |
| Plugins cannot import other plugins (same plugin's submodules are fine) |
β DO
import { PluginCommand } from '../../interfaces';
import { Modal, Controller, Figure } from '../../modules/contract';
import { dom, numbers } from '../../helper';
// inside the class, reach other L3 modules through $:
this.$.format.setLine(...);
this.$.selection.getRange();
β DON'T
// L3 module reaching another L3 module directly
import Selection from './selection'; // forbidden in src/core/logic/dom/*
// helper reaching into core
import { format } from '../../core/logic/dom/format'; // forbidden in src/helper/*
// plugin importing another plugin
import Link from '../link'; // forbidden in src/plugins/*
9. Plugin shape
import { PluginCommand } from '../../interfaces';
import { dom } from '../../helper';
class MyPlugin extends PluginCommand {
static key = 'myPlugin'; // required, lowercase
static type = 'command'; // required: 'command' | 'dropdown' | 'modal' | 'browser' | 'popup' | 'field' | 'input' | 'dropdown-free'
static className = 'se-btn-my'; // optional toolbar button class
constructor(kernel, pluginOptions) {
super(kernel); // required β injects this.$
this.title = this.$.lang.myPlugin;
this.icon = 'icon-name';
}
action(target) {
// required for PluginCommand
// ...
}
active(element, target) {
// optional β selection-change hook
if (element && element.nodeName === 'STRONG') {
dom.utils.addClass(target, 'active');
return true;
}
dom.utils.removeClass(target, 'active');
return false;
}
}
export default MyPlugin;
Required by base class:
| Base | Must implement | Optional |
|---|---|---|
PluginCommand | action(target) | active |
PluginDropdown | action(target) | on(target), off(), active |
PluginDropdownFree | β | on(target), off() |
PluginModal | open(target) | init, close |
PluginBrowser | open(onSelect), close() | β |
PluginPopup | show() | β |
PluginField / PluginInput | event hooks (onInput, onKeyDown, toolbarInputChange, etc.) | β |
β DON'T
- Skip
super(kernel)βthis.$is undefined. - Omit
static keyβ toolbar registration fails silently. - Register plugins as instances (
plugins: [new MyPlugin()]) β pass class references.
10. Modules β (host, $, element, ...) signature
Modal, Controller, Browser, Figure, etc. β instantiated by plugins:
this.modal = new Modal(this, this.$, modalElement);
this.controller = new Controller(this, this.$, controllerElement, {
position: 'bottom',
disabled: false,
parents: [],
isWWTarget: true,
});
this.figure = new Figure(this, this.$, imgElement, {
controls: [['mirror_h', 'mirror_v'], ['caption'], ['remove']],
});
First arg is the host plugin instance (this), second is $. Order matters β swapping breaks lifecycle hooks.
π‘ Modules receive $ directly (not kernel) because kernel instantiates modules indirectly through plugins; passing kernel would create a circular dep.
11. i18n β this.$.lang.<key>
this.title = this.$.lang.font;
this.title = this.$.lang.tag_blockquote;
- Add new keys to
src/langs/en.jsonly β other language files are auto-generated (seeediting-rules.md). - Prefer static keys over
this.$.lang[dynamicKey]. Dynamic lookups can't be validated by the translation script. - Hardcoded English strings in plugin UI = bug.
12. Async β await every public event call
Always await:
await this.$.eventManager.triggerEvent('onPaste', { frameContext, event, data });
await this._callPluginEventAsync('onFilePasteAndDrop', { frameContext, event, file });
The first form returns a Promise that may resolve to false (user canceled), true, the result of the handler, or the sentinel NO_EVENT (no handler registered). Without await, you compare a Promise β always truthy.
β Plugin action() being declared async without callers awaiting silently drops errors. If action is async, document it via JSDoc so the caller knows.
13. Private fields & JSDoc types
- Every instance field is
#privateField. Nothis._foo. No public state onthisunless it's part of the documented plugin API (e.g.,this.title,this.icon,this.modal,this.controller). - JSDoc types:
@param {SunEditor.Kernel}β only for constructor params taking the Kernel instance.@param {SunEditor.Deps}β everything else:this.$, event-callback$, module$.@param {SunEditor.FrameContext}for frame-scoped values.
See ARCHITECTURE.md Β§5 for the full type table.
14. Error handling
- Recoverable failures: log with
console.warn(prefix:[SunEditor.<area>.<reason>]) and return. - Unrecoverable invariants:
throw new Error('[SUNEDITOR.<area>.<fn>.fail] <reason>')β seeformat.setLinefor the format. - User event handlers are wrapped in
try/catchbyeventManager.triggerEventand log viaconsole.error. Don't swallow exceptions inside plugins; let the wrapper handle it. - No custom logger exists. Don't introduce one.
Quick checklist
Before saving:
- All DOM listeners go through
this.$.eventManager.addEvent/addGlobalEvent. - All wysiwyg mutations go through
$.html/$.format/$.inline(noinnerHTML, noappendChildinto wysiwyg). - State changes go through
store.set(no direct field writes). - Right state container:
storevscontextvsframeContextvsoptionsvsframeOptions. - No
instanceofβ usedom.check.*orthis.$.instanceCheck.*. - No
window./document.β use_w/_dfromhelper/env, orframeContext.get('_ww'/'_wd'). -
history.pushcalled exactly once per logical edit (and never after a wrapper that auto-pushes). -
awaitontriggerEventand_callPluginEventAsync. - No L3βL3 imports; no helperβcore imports; no pluginβplugin imports.
- Plugin has
static key,static type, andsuper(kernel)in constructor. - New lang keys added to
src/langs/en.jsonly. - Instance fields are
#private.