UNPKG

suneditor

Version:

Vanilla JavaScript based WYSIWYG web editor

576 lines (511 loc) 18.1 kB
import { PluginModal } from '../../interfaces'; import { Modal, Controller } from '../../modules/contract'; import { dom, env, converter } from '../../helper'; const { _w, _d } = env; /** * @typedef {Object} MathPluginOptions * @property {boolean} [canResize=true] - Whether the math modal can be resized. * @property {boolean} [autoHeight=false] - Whether to automatically adjust the height of the modal. * @property {Array<{text: string, value: string, default?: true}>} [fontSizeList] - A list of font size options for the math expression size selector. * ```js * // fontSizeList * [{ text: '1', value: '1em', default: true }, { text: '1.5', value: '1.5em' }, { text: '2', value: '2em' }] * ``` * @property {?(...args: *) => *} [onPaste] - A callback function to handle paste events in the math input area. * @property {Object} [formSize={}] - An object specifying the dimensions for the math modal. * @property {string} [formSize.width="460px"] - The default width of the math modal. * @property {string} [formSize.height] - The default height of the math modal. * - Defaults to `"14em"`. When `autoHeight` is `true`, defaults to `formSize.minHeight`. * @property {string} [formSize.maxWidth] - The maximum width of the math modal. * @property {string} [formSize.maxHeight] - The maximum height of the math modal. * @property {string} [formSize.minWidth="400px"] - The minimum width of the math modal. * @property {string} [formSize.minHeight="40px"] - The minimum height of the math modal. */ /** * @class * @description Math plugin. * - This plugin provides support for rendering mathematical expressions using either the `KaTeX` or `MathJax` libraries. * - If external library is provided, a warning is issued. */ class Math_ extends PluginModal { static key = 'math'; static className = ''; /** * @param {HTMLElement} node - The node to check. * @returns {HTMLElement|null} Returns a node if the node is a valid component. */ static component(node) { return dom.utils.hasClass(node, 'se-math|katex') && dom.check.isComponentContainer(node) ? node : null; } #element = null; /** * @constructor * @param {SunEditor.Kernel} kernel - The Kernel instance * @param {MathPluginOptions} pluginOptions */ constructor(kernel, pluginOptions) { // plugin basic properties super(kernel); this.title = this.$.lang.math; this.icon = 'math'; // external library this.katex = null; this.mathjax = null; // exception if (!(this.katex = this.#CheckKatex()) && !(this.mathjax = this.#CheckMathJax())) { console.warn( '[SUNEDITOR.plugins.math.warn] The math plugin must need either "KaTeX" or "MathJax" library. Please add the katex or mathjax option. See: https://github.com/ARA-developer/suneditor/blob/develop/guide/external-libraries.md', ); } this.pluginOptions = { formSize: { width: '460px', height: '14em', maxWidth: '', maxHeight: '', minWidth: '400px', minHeight: '40px', ...pluginOptions.formSize, }, canResize: pluginOptions.canResize ?? true, autoHeight: !!pluginOptions.autoHeight, fontSizeList: pluginOptions.fontSizeList || [ { text: '1', value: '1em', }, { text: '1.5', value: '1.5em', }, { text: '2', value: '2em', }, { text: '2.5', value: '2.5em', }, ], onPaste: typeof pluginOptions.onPaste === 'function' ? pluginOptions.onPaste : null, }; if (this.pluginOptions.autoHeight) { this.pluginOptions.formSize.height = this.pluginOptions.formSize.minHeight; } // create HTML this.defaultFontSize = null; const modalEl = CreateHTML_modal(this); const controllerEl = CreateHTML_controller(this.$); // modules this.modal = new Modal(this, this.$, modalEl); this.controller = new Controller(this, this.$, controllerEl, { position: 'bottom', disabled: true }); // members /** @type {HTMLTextAreaElement} */ this.textArea = modalEl.querySelector('.se-math-exp'); /** @type {HTMLPreElement} */ this.previewElement = modalEl.querySelector('.se-math-preview'); /** @type {HTMLSelectElement} */ this.fontSizeElement = modalEl.querySelector('.se-math-size'); this.isUpdateState = false; // init this.previewElement.style.fontSize = this.defaultFontSize; this.$.eventManager.addEvent(this.textArea, 'input', this.#RenderMathExp.bind(this)); this.$.eventManager.addEvent( this.fontSizeElement, 'change', function (e) { this.fontSize = e.target.value; }.bind(this.previewElement.style), ); if (this.pluginOptions.onPaste) { this.$.eventManager.addEvent(this.textArea, 'paste', this.pluginOptions.onPaste.bind(this)); } } /** * @override * @type {PluginModal['open']} */ open() { this.modal.open(); } /** * @hook Editor.Core * @type {SunEditor.Hook.Core.RetainFormat} */ retainFormat() { return { query: '.se-math, .katex, .MathJax', method: (element) => { if (!this.katex && !this.mathjax) return; const value = getValue(element); if (!value) return; const domParser = _d.createRange().createContextualFragment(this.#renderer(converter.entityToHTML(this.#escapeBackslashes(value, true)))); element.innerHTML = domParser.querySelector('.se-math, .katex').innerHTML; element.setAttribute('contenteditable', 'false'); dom.utils.addClass(element, 'se-component|se-inline-component|se-disable-pointer|se-math'); if (this.katex) { dom.utils.addClass(element, 'katex'); } else { dom.utils.removeClass(element, 'katex'); } if (this.mathjax) { this.#renderMathJax(this.mathjax); } }, }; } /** * @hook Modules.Modal * @type {SunEditor.Hook.Modal.On} */ modalOn(isUpdate) { this.isUpdateState = isUpdate; if (!isUpdate) { this.modalInit(); } else if (this.controller.currentTarget) { const currentTarget = this.controller.currentTarget; const exp = converter.entityToHTML(this.#escapeBackslashes(getValue(currentTarget), true)); const fontSize = getType(currentTarget) || '1em'; this.textArea.value = exp; this.fontSizeElement.value = fontSize; this.previewElement.innerHTML = this.#renderer(exp); this.previewElement.style.fontSize = fontSize; } } /** * @hook Modules.Modal * @type {SunEditor.Hook.Modal.Action} */ async modalAction() { if (this.textArea.value.trim().length === 0 || dom.utils.hasClass(this.textArea, 'se-error')) { this.textArea.focus(); return false; } const mathExp = this.textArea.value; /** @type {HTMLSpanElement} */ const mathEl = this.previewElement.querySelector('.se-math, .katex'); if (!mathEl) return false; dom.utils.addClass(mathEl, 'se-component|se-inline-component|se-disable-pointer|se-math'); mathEl.setAttribute('contenteditable', 'false'); mathEl.setAttribute('data-se-value', converter.htmlToEntity(this.#escapeBackslashes(mathExp, false))); mathEl.setAttribute('data-se-type', this.fontSizeElement.value); mathEl.style.fontSize = this.fontSizeElement.value; if (this.katex) { dom.utils.addClass(mathEl, 'katex'); dom.utils.removeClass(mathEl, 'MathJax'); } else { dom.utils.removeClass(mathEl, 'katex'); } if (!this.isUpdateState) { const selectedFormats = this.$.format.getLines(); if (selectedFormats.length > 1) { const oFormat = dom.utils.createElement(selectedFormats[0].nodeName, null, mathEl); this.$.component.insert(oFormat, { insertBehavior: 'none', scrollTo: false }); } else { this.$.component.insert(mathEl, { insertBehavior: 'none', scrollTo: false }); } } else { const containerEl = dom.query.getParentElement(this.controller.currentTarget, '.se-component'); containerEl.replaceWith(mathEl); const compInfo = this.$.component.get(mathEl); this.$.component.select(compInfo.target, compInfo.pluginName); return true; } if (this.mathjax) { this.#renderMathJax(this.mathjax); } const r = this.$.selection.getNearRange(mathEl); if (r) { this.$.selection.setRange(r.container, r.offset, r.container, r.offset); } else { this.$.component.select(mathEl, Math_.key); } return true; } /** * @hook Modules.Modal * @type {SunEditor.Hook.Modal.Init} */ modalInit() { this.textArea.value = ''; this.previewElement.innerHTML = ''; dom.utils.removeClass(this.textArea, 'se-error'); } /** * @hook Modules.Controller * @type {SunEditor.Hook.Controller.Action} */ controllerAction(target) { const command = target.getAttribute('data-command'); switch (command) { case 'update': this.modal.open(); break; case 'copy': this.#copyTextToClipboard(this.#element); break; case 'delete': this.componentDestroy(this.controller.currentTarget); } } /** * @hook Modules.Controller * @type {SunEditor.Hook.Controller.Close} */ controllerClose() { this.#element = null; } /** * @hook Editor.Component * @type {SunEditor.Hook.Component.Select} */ componentSelect(target) { if (dom.utils.hasClass(target, 'se-math|katex') && getValue(target)) { this.#element = target; this.controller.open(target, null, { isWWTarget: false, initMethod: null, addOffset: null }); return; } } /** * @hook Editor.Component * @type {SunEditor.Hook.Component.Destroy} */ async componentDestroy(target) { dom.utils.removeItem(target); this.controller.close(); this.$.focusManager.focus(); this.$.history.push(false); } /** * @description Renders the given math expression using `KaTeX` or `MathJax`. * @param {string} exp - The math expression to render. * @returns {string} - The rendered math expression as HTML. */ #renderer(exp) { let result = ''; try { dom.utils.removeClass(this.textArea, 'se-error'); if (this.katex) { result = this.katex.src.renderToString(exp, { throwOnError: true, displayMode: true }); } else if (this.mathjax) { result = this.mathjax.convert(exp).outerHTML; if (/<mjx-merror/.test(result)) { dom.utils.addClass(this.textArea, 'se-error'); result = `<span class="se-math-error">${result}</span>`; } else { result = `<span class="se-math">${result}</span>`; } } else { /** @type {Error & { code?: string }} */ const err = new Error('404 Not found. "KaTeX" or "MathJax" library'); err.code = 'MATH_LIB_NOT_FOUND'; throw err; } } catch (error) { dom.utils.addClass(this.textArea, 'se-error'); if (error.code === 'MATH_LIB_NOT_FOUND') { result = `<span class="se-math-error">${error.message}</span>`; } else { result = `<span class="se-math-error">Math syntax error. (Refer ${this.katex ? `<a href="${env.KATEX_WEBSITE}" target="_blank">KaTeX</a>` : `<a href="${env.MATHJAX_WEBSITE}" target="_blank">MathJax</a>`})</span>`; } console.warn('[SUNEDITOR.math.error] ', error.message); } return result; } /** * @description Escapes or unescapes backslashes in a given string. * @param {string} str - The input string. * @param {boolean} decode - If `true`, decodes escaped backslashes; otherwise, encodes them. * @returns {string} - The processed string. */ #escapeBackslashes(str, decode) { return str.replace(/\\{2}/g, decode ? '\\' : '\\\\'); } /** * @description Copies the math expression text to clipboard. * @param {Node} element - The math expression element. * @returns {Promise<void>} */ async #copyTextToClipboard(element) { if (!navigator.clipboard || !element) return; try { const text = getValue(element); await this.$.html.copy(text); dom.utils.addClass(element, 'se-copy'); // copy effect _w.setTimeout(() => { dom.utils.removeClass(element, 'se-copy'); }, 120); } catch (err) { console.error('[SUNEDITOR.math.copy.fail]', err); } } /** * @description Handles rendering of math expressions in the preview. * @param {InputEvent} e - The input event. */ #RenderMathExp(e) { /** @type {HTMLInputElement} */ const eventTarget = dom.query.getEventTarget(e); if (this.pluginOptions.autoHeight) { eventTarget.style.height = '5px'; eventTarget.style.height = eventTarget.scrollHeight + 5 + 'px'; } this.previewElement.innerHTML = this.#renderer(eventTarget.value); if (this.mathjax) this.#renderMathJax(this.mathjax); } /** * @param {*} mathjax - The MathJax instance. */ #renderMathJax(mathjax) { mathjax.clear(); mathjax.updateDocument(); } /** * @returns {*} - The `KaTeX` instance or `null` if the instance is invalid. */ #CheckKatex() { const katex = this.$.options.get('externalLibs').katex; if (!katex) return null; if (!katex.src) { console.warn('[SUNEDITOR.math.katex.fail] The katex option is set incorrectly.'); return null; } const katexOptions = [ { throwOnError: false, }, katex.options || {}, ].reduce((init, option) => { for (const key in option) { init[key] = option[key]; } return init; }, {}); katex.options = katexOptions; return katex; } /** * @returns {*} */ #CheckMathJax() { const mathjax = this.$.options.get('externalLibs').mathjax; if (!mathjax) return null; if (this.$.frameOptions.get('iframe')) { console.warn('[SUNEDITOR.math.mathjax.fail] The MathJax option is not supported in the iframe.'); } try { const adaptor = mathjax.browserAdaptor(); mathjax.RegisterHTMLHandler(adaptor); const tex = new mathjax.TeX(); const chtml = new mathjax.CHTML(); return mathjax.src.document(document, { InputJax: tex, OutputJax: chtml, }); } catch (error) { console.warn('[SUNEDITOR.math.mathjax.fail] The MathJax option is set incorrectly.', error); return null; } } } /** * @param {Math_} inst - Math plugin instance * @returns {HTMLElement} */ function CreateHTML_modal(inst) { const { $, pluginOptions, katex } = inst; const { lang, icons } = $; const { formSize, fontSizeList, canResize, autoHeight } = pluginOptions; const { width, height, maxWidth, maxHeight, minWidth, minHeight } = formSize; const resizeType = !canResize ? 'none' : autoHeight ? 'horizontal' : 'auto'; let defaultFontSize = fontSizeList[0].value; let html = /*html*/ ` <form> <div class="se-modal-header"> <button type="button" data-command="close" class="se-btn se-close-btn" title="${lang.close}" aria-label="${lang.close}"> ${icons.cancel} </button> <span class="se-modal-title">${lang.math_modal_title}</span> </div> <div class="se-modal-body"> <div class="se-modal-form"> <label>${lang.math_modal_inputLabel} ${katex ? `(<a href="${env.KATEX_WEBSITE}" target="_blank">KaTeX</a>)` : `(<a href="${env.MATHJAX_WEBSITE}" target="_blank">MathJax</a>)`}</label> <textarea class="se-input-form se-math-exp se-modal-resize-form" type="text" data-focus style="width: ${width}; height: ${height}; min-width: ${minWidth}; min-height: ${minHeight}; resize: ${resizeType};"></textarea> </div> <div class="se-modal-form"> <label>${lang.math_modal_fontSizeLabel}</label> <select class="se-input-select se-math-size">`; for (let i = 0, len = fontSizeList.length, f; i < len; i++) { f = fontSizeList[i]; if (f.default) defaultFontSize = f.value; html += /*html*/ `<option value="${f.value}"${f.default ? ' selected' : ''}>${f.text}</option>`; } html += /*html*/ `</select> </div> <div class="se-modal-form"> <label>${lang.math_modal_previewLabel}</label> <p class="se-math-preview"></p> </div> </div> <div class="se-modal-footer"> <button type="submit" class="se-btn-primary" title="${lang.submitButton}" aria-label="${lang.submitButton}"> <span>${lang.submitButton}</span> </button> </div> </form>`; inst.defaultFontSize = defaultFontSize; return dom.utils.createElement('DIV', { class: 'se-modal-content se-modal-responsive', style: `max-width: ${maxWidth}; max-height: ${maxHeight};` }, html); } /** * @param {SunEditor.Deps} $ - Kernel dependencies * @returns {HTMLElement} */ function CreateHTML_controller({ lang, icons }) { const html = /*html*/ ` <div class="se-arrow se-arrow-up"></div> <div class="link-content"> <div class="se-btn-group"> <button type="button" data-command="update" tabindex="-1" class="se-btn se-tooltip"> ${icons.edit} <span class="se-tooltip-inner"> <span class="se-tooltip-text">${lang.edit}</span> </span> </button> <button type="button" data-command="copy" tabindex="-1" class="se-btn se-tooltip"> ${icons.copy} <span class="se-tooltip-inner"> <span class="se-tooltip-text">${lang.copy}</span> </span> </button> <button type="button" data-command="delete" tabindex="-1" class="se-btn se-tooltip"> ${icons.delete} <span class="se-tooltip-inner"> <span class="se-tooltip-text">${lang.remove}</span> </span> </button> </div> </div>`; return dom.utils.createElement('DIV', { class: 'se-controller se-controller-link' }, html); } function getValue(element) { const seAttr = element.getAttribute('data-se-value'); if (seAttr) return seAttr; // v2-migration const v2SeAttr = element.getAttribute(`data-exp`); if (!v2SeAttr) return null; element.removeAttribute(`data-exp`); element.setAttribute(`data-se-value`, v2SeAttr); return v2SeAttr; } function getType(element) { const seAttr = element.getAttribute('data-se-type'); if (seAttr) return seAttr; // v2-migration const v2SeAttr = element.getAttribute(`data-font-size`); if (!v2SeAttr) return null; element.removeAttribute(`data-font-size`); element.setAttribute(`data-se-type`, v2SeAttr); return v2SeAttr; } export default Math_;