suneditor
Version:
Vanilla JavaScript based WYSIWYG web editor
304 lines (260 loc) • 9.82 kB
JavaScript
import { env, converter, dom } from '../helper';
import Constructor from './section/constructor';
// type
import DocumentType from './section/documentType';
// kernel
import CoreKernel from './kernel/coreKernel';
/**
* @description SunEditor class.
*/
class Editor {
#kernel;
/** @type {SunEditor.Deps} */
$;
/**
* @constructor
* @description SunEditor constructor function.
* @param {Array<{target: Element, key: *, options: SunEditor.InitFrameOptions}>} multiTargets Target element
* @param {SunEditor.InitOptions} options options
*/
constructor(multiTargets, options) {
const product = Constructor(multiTargets, options);
// CoreKernel
const kernel = new CoreKernel(this, { product, options });
this.#kernel = kernel;
this.$ = kernel.$;
this.#Create(options).catch((e) => {
console.error('[SUNEDITOR:E_CREATE_FAIL] Failed to create editor instance.', e);
});
}
/**
* @description Checks if the content of the editor is empty.
* - Display criteria for "placeholder".
* @param {?SunEditor.FrameContext} [fc] Frame context, if not present, currently selected frame context.
* @returns {boolean}
*/
isEmpty(fc) {
const wysiwyg = (fc || this.$.frameContext).get('wysiwyg');
return dom.check.isZeroWidth(wysiwyg.textContent) && !wysiwyg.querySelector(this.$.options.get('allowedEmptyTags')) && (wysiwyg.innerText.match(/\n/g) || '').length <= 1;
}
/**
* @description Add or reset option property (Editor is reloaded)
* @example
* // Change toolbar buttons and height
* editor.resetOptions({
* buttonList: [['bold', 'italic'], ['image']],
* height: '500px',
* });
* @param {SunEditor.InitOptions} newOptions Options
*/
resetOptions(newOptions) {
this.$.optionProvider.reset(newOptions);
this.$.store.set('_lastSelectionNode', null);
this.#setFrameInfo(this.$.frameRoots.get(this.$.store.get('rootKey')));
// plugin hook
for (const plugin of Object.values(this.$.plugins)) {
plugin.init?.();
}
}
/**
* @description Change the current root index.
* @example
* // Switch to the 'body' frame in a multi-root editor
* editor.changeFrameContext('body');
*
* // Switch back to the 'header' frame
* editor.changeFrameContext('header');
* @param {*} rootKey Root frame key.
*/
changeFrameContext(rootKey) {
if (rootKey === this.$.store.get('rootKey')) return;
this.$.store.set('rootKey', rootKey);
this.#setFrameInfo(this.$.frameRoots.get(rootKey));
this.$.toolbar._resetSticky();
}
/**
* @description Destroy the suneditor
*/
destroy() {
/** destroy external library */
if (this.$.options.get('hasCodeMirror')) {
this.$.contextProvider.applyToRoots((e) => {
const opts = e.get('options');
const cm = opts.get('codeMirrorEditor');
if (cm) cm.toTextArea();
});
}
/** remove DOM elements */
dom.utils.removeItem(this.$.context.get('toolbar_wrapper'));
dom.utils.removeItem(this.$.context.get('toolbar_sub_wrapper'));
dom.utils.removeItem(this.$.context.get('statusbar_wrapper'));
/** clear events */
for (const k in this.events) {
this.events[k] = null;
}
this.events = null;
/** destroy kernel (handles all internal cleanup) */
this.#kernel._destroy();
return null;
}
/**
* @description Set frameContext, frameOptions
* @param {SunEditor.FrameContext} rt Root target[key] FrameContext
*/
#setFrameInfo(rt) {
this.$.contextProvider.reset(rt);
this.$.optionProvider.resetFrame(rt.get('options'));
this.$.ui.reset(rt);
}
/**
* @description Initializ editor
* @param {SunEditor.InitOptions} options Options
*/
#editorInit(options) {
this.$.store.set('initViewportHeight', env._w.visualViewport.height);
this.#kernel._eventOrchestrator.__setViewportSize();
this.$.contextProvider.init();
// initialize core and add event listeners
this.#setFrameInfo(this.$.frameRoots.get(this.$.store.get('rootKey')));
this.#init(options);
this.$.contextProvider.applyToRoots((e) => {
this.#kernel._eventOrchestrator._addFrameEvents(e);
this.#initWysiwygArea(e, e.get('options').get('value'));
if (e.get('options').get('iframe') && e.get('options').get('height') === 'auto') {
this.$.ui._emitResizeEvent(e, e.get('wysiwygFrame').offsetHeight, null);
}
});
this.#kernel._eventOrchestrator.__eventDoc = null;
this.$.store._editorInitFinished = true;
this.$.pluginManager.checkFileInfo(true);
// history reset
this.$.history.reset();
// Defer post-init tasks (observers, history reset, plugin init, onload) to allow DOM to settle after iframe/wysiwyg insertion
env._w.setTimeout(() => {
// Check if instance was destroyed (e.g., in SSR with dynamic imports mistake)
if (!this.$.context?.size) {
console.warn('[SUNEDITOR:E_INIT_FAIL] Editor instance was destroyed before initialization completed. Check if destroy() was called.');
return;
}
// toolbar visibility
this.$.context.get('toolbar_main').style.visibility = '';
// roots
this.$.contextProvider.applyToRoots((e) => {
// observer
if (this.#kernel._eventOrchestrator._wwFrameObserver) this.#kernel._eventOrchestrator._wwFrameObserver.observe(e.get('wysiwygFrame'));
if (this.#kernel._eventOrchestrator._toolbarObserver) this.#kernel._eventOrchestrator._toolbarObserver.observe(e.get('_toolbarShadow'));
// resource state
this.$.ui._syncFrameState(e);
});
// plugin hook
for (const plugin of Object.values(this.$.plugins)) {
plugin.init?.();
}
// class init
this.$.selection.__init();
// user event
this.$.eventManager.triggerEvent('onload', {});
}, 0);
}
/**
* @description Initializ wysiwyg area (Only called from core._init)
* @param {SunEditor.FrameContext} e frameContext
* @param {string} value initial html string
*/
#initWysiwygArea(e, value) {
// set content
e.get('wysiwyg').innerHTML =
this.$.html.clean(typeof value === 'string' ? value : (/^TEXTAREA$/i.test(e.get('originElement').nodeName) ? e.get('originElement').value : e.get('originElement').innerHTML) || '', {
forceFormat: true,
whitelist: null,
blacklist: null,
_freeCodeViewMode: this.$.options.get('freeCodeViewMode'),
}) || '<' + this.$.options.get('defaultLine') + '><br></' + this.$.options.get('defaultLine') + '>';
// char counter
if (e.has('charCounter')) e.get('charCounter').textContent = String(this.$.char.getLength());
// word counter
if (e.has('wordCounter')) e.get('wordCounter').textContent = String(this.$.char.getWordCount());
// document type init
if (this.$.options.get('type') === 'document') {
e.set('documentType', new DocumentType(this.#kernel, e));
if (e.get('documentType').useHeader) {
e.set('documentType_use_header', true);
}
if (e.get('documentType').usePage) {
e.set('documentType_use_page', true);
e.get('documentTypePageMirror').innerHTML = e.get('wysiwyg').innerHTML;
}
}
}
/**
* @description Initializ core variable
* @param {SunEditor.InitOptions} options Options
*/
#init(options) {
this.$.pluginManager.init(options);
this.$.shortcuts._registerShortcuts();
this.$.commandDispatcher._initCommandButtons();
this.$.ui.init();
}
/**
* @description Configures the document properties of an iframe editor.
* @param {HTMLIFrameElement} frame - The editor iframe.
* @param {SunEditor.Options} originOptions - The original options.
* @param {SunEditor.FrameOptions} targetOptions - The new options.
*/
#setIframeDocument(frame, originOptions, targetOptions) {
frame.contentDocument.documentElement.className = 'sun-editor';
frame.contentDocument.head.innerHTML =
'<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">' +
converter._setIframeStyleLinks(targetOptions.get('iframe_cssFileName')) +
converter._setAutoHeightStyle(targetOptions.get('height'));
frame.contentDocument.body.className = originOptions.get('_editableClass');
frame.contentDocument.body.setAttribute('contenteditable', 'true');
}
/**
* @description Creates the editor instance and initializes components.
* @param {SunEditor.InitOptions} originOptions - The initial editor options.
* @returns {Promise<void>}
*/
async #Create(originOptions) {
// common events
this.#kernel._eventOrchestrator._addCommonEvents();
// init
const iframePromises = [];
this.$.contextProvider.applyToRoots((e) => {
const o = e.get('originElement');
const t = e.get('topArea');
o.style.display = 'none';
t.style.display = 'block';
o.parentNode.insertBefore(t, o.nextElementSibling);
if (e.get('options').get('iframe')) {
const iframeLoaded = new Promise((resolve) => {
this.$.eventManager.addEvent(e.get('wysiwygFrame'), 'load', ({ target }) => {
this.#setIframeDocument(/** @type{HTMLIFrameElement} */ (target), this.$.optionProvider.options, e.get('options'));
resolve();
});
});
iframePromises.push(iframeLoaded);
}
});
this.$.contextProvider.applyToRoots((e) => {
e.get('wrapper').appendChild(e.get('wysiwygFrame'));
// document type
if (e.get('documentTypeInner')) {
if (this.$.options.get('_rtl')) e.get('wrapper').appendChild(e.get('documentTypeInner'));
else e.get('wrapper').insertBefore(e.get('documentTypeInner'), e.get('wysiwygFrame'));
}
if (e.get('documentTypePage')) {
if (this.$.options.get('_rtl')) e.get('wrapper').insertBefore(e.get('documentTypePage'), e.get('wysiwygFrame'));
else e.get('wrapper').appendChild(e.get('documentTypePage'));
// page mirror
e.get('wrapper').appendChild(e.get('documentTypePageMirror'));
}
});
if (iframePromises.length > 0) {
await Promise.all(iframePromises);
}
this.#editorInit(originOptions);
}
}
export default Editor;