UNPKG

suneditor

Version:

Vanilla JavaScript based WYSIWYG web editor

424 lines (371 loc) 13.7 kB
import { dom, env, keyCodeMap } from '../../../helper'; import { isTable, isList } from '../../../helper/dom/domCheck'; const { NO_EVENT } = env; /** * @constant {Object.<string, string[]>} StyleMap - Map of font styles to CSS properties. */ const StyleMap = { bold: ['font-weight'], underline: ['text-decoration'], italic: ['font-style'], strike: ['text-decoration'], }; /** * @description Executes built-in editor commands (formatting, undo/redo, save, codeView, etc.) * - and manages copy-format state. */ export default class CommandExecutor { #kernel; #$; #store; #frameContext; #options; #eventManager; #globalEventKeydown = null; #globalEventMousedown = null; /** * @description Copy format info * - `eventManager.__cacheStyleNodes` copied * @type {?Array<Node>} */ #onCopyFormatInfo = null; /** * @description Copy format init method * @type {?(...args: *) => *} */ #onCopyFormatInitMethod = null; /** * @constructor * @param {SunEditor.Kernel} kernel */ constructor(kernel) { this.#kernel = kernel; this.#$ = kernel.$; this.#store = kernel.store; this.#frameContext = this.#$.frameContext; this.#options = this.#$.options; this.#eventManager = this.#$.eventManager; } /** * @description Execute default command of command button */ async execute(command, button) { if (this.#frameContext.get('isReadOnly') && !/copy|cut|selectAll|selectAll_full|codeView|markdownView|fullScreen|print|preview|showBlocks|finder/.test(command)) return; switch (command) { case 'selectAll': this.#SELECT_ALL(); break; case 'selectAll_full': this.#SELECT_ALL_FULL(); break; case 'copy': { const range = this.#$.selection.getRange(); if (range.collapsed) break; const container = dom.utils.createElement('div', null, range.cloneContents()); await this.#$.html.copy(container.innerHTML); break; } case 'newDocument': this.#$.html.set(`<${this.#options.get('defaultLine')}><br></${this.#options.get('defaultLine')}>`); this.#$.focusManager.focus(); this.#$.history.push(false); // document type if (this.#frameContext.has('documentType_use_header')) { this.#frameContext.get('documentType').reHeader(); } break; case 'finder': this.#$.finder.open(true); break; case 'codeView': this.#$.viewer.codeView(!this.#frameContext.get('isCodeView')); break; case 'markdownView': this.#$.viewer.markdownView(!this.#frameContext.get('isMarkdownView')); break; case 'fullScreen': this.#$.viewer.fullScreen(!this.#frameContext.get('isFullScreen')); break; case 'indent': this.#$.format.indent(); break; case 'outdent': this.#$.format.outdent(); break; case 'undo': this.#$.history.undo(); break; case 'redo': this.#$.history.redo(); break; case 'removeFormat': this.#$.inline.remove(); this.#$.focusManager.focus(); break; case 'print': this.#$.viewer.print(); break; case 'preview': this.#$.viewer.preview(); break; case 'showBlocks': this.#$.viewer.showBlocks(!this.#frameContext.get('isShowBlocks')); break; case 'dir': this.#$.ui.setDir(this.#options.get('_rtl') ? 'ltr' : 'rtl'); break; case 'dir_ltr': this.#$.ui.setDir('ltr'); break; case 'dir_rtl': this.#$.ui.setDir('rtl'); break; case 'save': await this.#SAVE(); break; case 'copyFormat': this.#COPY_FORMAT(button); break; case 'pageBreak': this.#PAGE_BREAK(); break; case 'pageUp': this.#frameContext.get('documentType').pageUp(); break; case 'pageDown': this.#frameContext.get('documentType').pageDown(); break; default: this.#FONT_STYLE(command); } } copyFormat() { if (!this.#onCopyFormatInfo?.length) return; try { const _styleNode = [...this.#onCopyFormatInfo]; const n = _styleNode.pop(); this.#$.inline.remove(); if (n) { const insertedNode = this.#$.inline.apply(n, { stylesToModify: null, nodesToRemove: [n.nodeName], strictRemove: false }); const { parent, inner } = this.#$.nodeTransform.createNestedNode(_styleNode); insertedNode.parentNode.insertBefore(parent, insertedNode); inner.appendChild(insertedNode); this.#$.selection.setRange(insertedNode, dom.check.isZeroWidth(insertedNode) ? 1 : 0, insertedNode, 1); } if (this.#options.get('copyFormatKeepOn')) return; this.#onCopyFormatInitMethod(); } catch (err) { console.warn('[SUNEDITOR.copyFormat.error] ', err); if (!this.#onCopyFormatInitMethod?.()) { this.#onCopyFormatInfo = null; this.#onCopyFormatInitMethod = null; } } } /** * @description Selects all content in the editor. */ #SELECT_ALL() { this.#$.ui.offCurrentController(); this.#$.menu.containerOff(); // check all tags const ww = this.#frameContext.get('wysiwyg'); let prevScopeTag = null; let prevScopeTagName = ''; const scopeSelectionTags = this.#options.get('scopeSelectionTags'); const range = this.#$.selection.getRange(); if (!range.collapsed) { let commonNode = (prevScopeTag = range.commonAncestorContainer); let commonNodeName = (prevScopeTagName = commonNode.nodeName?.toLowerCase()); if (range.startOffset === 0 && range.endOffset === range.endContainer.textContent?.length) { const commonParent = commonNode.parentElement; if ((dom.check.isList(commonParent) || dom.check.isListCell(commonParent)) && commonParent.firstChild.contains?.(range.startContainer) && commonParent.lastChild?.contains(range.endContainer)) { prevScopeTag = commonNode = commonParent.parentElement; prevScopeTagName = commonNode.nodeName?.toLowerCase(); } } commonNodeName = commonNode.nodeName?.toLowerCase(); while (commonNode && ((!commonNode.nextSibling && !commonNode.previousSibling && !scopeSelectionTags.includes(commonNodeName)) || dom.check.isContentLess(commonNodeName)) && commonNode !== ww) { commonNode = commonNode.parentElement; commonNodeName = commonNode.nodeName?.toLowerCase(); } if (scopeSelectionTags.includes(commonNodeName)) { prevScopeTag = commonNode; prevScopeTagName = commonNodeName; } } // select all const scopeTagList = scopeSelectionTags.filter((tagName) => tagName !== prevScopeTagName); const scopeBaseTag = dom.query.getParentElement(prevScopeTag || this.#$.selection.getNode(), (current) => scopeTagList.includes(current.nodeName?.toLowerCase())); let selectArea = scopeBaseTag || ww; let { first, last } = __findFirstAndLast(selectArea); if (!first || !last) return; const isZeroWidth = dom.check.isZeroWidth; while (isZeroWidth(first) && isZeroWidth(last) && selectArea !== ww) { selectArea = selectArea.parentElement; ({ first, last } = __findFirstAndLast(dom.query.getParentElement(selectArea, (current) => scopeTagList.includes(current.nodeName?.toLowerCase())) || ww)); } if (!first || !last) return; let info = null; if (dom.check.isMedia(first) || (info = this.#$.component.get(first)) || dom.check.isTableElements(first)) { info ||= this.#$.component.get(first); const br = dom.utils.createElement('BR'); const format = dom.utils.createElement(this.#options.get('defaultLine'), null, br); first = info ? info.container || info.cover : first; first.parentElement.insertBefore(format, first); first = br; } if (dom.check.isMedia(last) || (info = this.#$.component.get(last)) || dom.check.isTableElements(last)) { info ||= this.#$.component.get(last); const br = dom.utils.createElement('BR'); const format = dom.utils.createElement(this.#options.get('defaultLine'), null, br); last = info ? info.container || info.cover : last; last.parentElement.appendChild(format); last = br; } this.#$.toolbar._showBalloon(this.#$.selection.setRange(first, 0, last, last.textContent.length)); } /** * @description Selects all content in the entire editor without scope stepping. */ #SELECT_ALL_FULL() { this.#$.ui.offCurrentController(); this.#$.menu.containerOff(); const ww = this.#frameContext.get('wysiwyg'); let { first, last } = __findFirstAndLast(ww); if (!first || !last) return; let info = null; if (dom.check.isMedia(first) || (info = this.#$.component.get(first)) || dom.check.isTableElements(first)) { info ||= this.#$.component.get(first); const br = dom.utils.createElement('BR'); const format = dom.utils.createElement(this.#options.get('defaultLine'), null, br); first = info ? info.container || info.cover : first; first.parentElement.insertBefore(format, first); first = br; } if (dom.check.isMedia(last) || (info = this.#$.component.get(last)) || dom.check.isTableElements(last)) { info ||= this.#$.component.get(last); const br = dom.utils.createElement('BR'); const format = dom.utils.createElement(this.#options.get('defaultLine'), null, br); last = info ? info.container || info.cover : last; last.parentElement.appendChild(format); last = br; } this.#$.toolbar._showBalloon(this.#$.selection.setRange(first, 0, last, last.textContent.length)); } /** * @description Saves the editor content. * @returns {Promise<void>} */ async #SAVE() { const fc = this.#frameContext; if (!fc.get('isChanged')) return; const data = this.#$.html.get(); const saved = await this.#eventManager.triggerEvent('onSave', { frameContext: fc, data }); if (saved === NO_EVENT) { const origin = fc.get('originElement'); if (/^TEXTAREA$/i.test(origin.nodeName)) { origin.value = data; } else { origin.innerHTML = data; } } else if (saved === false) { return; } fc.set('isChanged', false); fc.set('savedIndex', this.#$.history.getRootStack()[this.#store.get('rootKey')].index); // set save button disable this.#$.commandDispatcher.applyTargets('save', (e) => { e.disabled = true; }); } /** * @description Copies formatting from selected text. * @param {Node} button - The button triggering the copy format function. */ #COPY_FORMAT(button) { if (typeof this.#onCopyFormatInitMethod === 'function') { this.#onCopyFormatInitMethod(); return; } const ww = this.#frameContext.get('wysiwyg'); this.#onCopyFormatInfo = [...this.#kernel._eventOrchestrator.__cacheStyleNodes]; this.#onCopyFormatInitMethod = this.#removeCopyformt.bind(this, this.#eventManager, ww, button); dom.utils.addClass(ww, 'se-copy-format-cursor'); dom.utils.addClass(button, 'on'); this.#globalEventKeydown = this.#eventManager.addGlobalEvent('keydown', (e) => { if (!keyCodeMap.isEsc(e.code)) return; this.#onCopyFormatInitMethod?.(); }); this.#globalEventMousedown = this.#eventManager.addGlobalEvent('mousedown', (e) => { if (ww.contains(e.target) || e.target === button) return; this.#onCopyFormatInitMethod?.(); }); } /** * @param {import('../../config/eventManager').default} eventManager * @param {Node} ww Wywsiwyg element * @param {Node} button Button element */ #removeCopyformt(eventManager, ww, button) { this.#globalEventKeydown = eventManager.removeGlobalEvent(this.#globalEventKeydown); this.#globalEventMousedown = eventManager.removeGlobalEvent(this.#globalEventMousedown); this.#onCopyFormatInfo = null; this.#onCopyFormatInitMethod = null; dom.utils.removeClass(ww, 'se-copy-format-cursor'); dom.utils.removeClass(button, 'on'); return true; } /** * @description Applies font styling to selected text. * @param {string} command - The font style command (e.g., bold, italic, underline). */ #FONT_STYLE(command) { command = this.#options.get('_defaultTagCommand')[command.toLowerCase()] || command; let nodeName = this.#options.get('convertTextTags')[command] || command; const nodesMap = this.#store.get('currentNodesMap'); const el = nodesMap.includes(this.#options.get('_styleCommandMap')[nodeName]) ? null : dom.utils.createElement(nodeName); if (/^sub$/i.test(nodeName) && nodesMap.includes('superscript')) { nodeName = 'sup'; } else if (/^sup$/i.test(nodeName) && nodesMap.includes('subscript')) { nodeName = 'sub'; } this.#$.inline.apply(el, { stylesToModify: StyleMap[command] || null, nodesToRemove: [nodeName], strictRemove: false }); this.#$.focusManager.focus(); } /** * @description Inserts a page break element into the editor. */ #PAGE_BREAK() { const pageBreak = dom.utils.createElement('DIV', { class: 'se-component se-component-line-break se-page-break' }); this.#$.component.insert(pageBreak, { skipCharCount: true, insertBehavior: 'line' }); const line = pageBreak.nextElementSibling || this.#$.format.addLine(pageBreak); this.#$.selection.setRange(line, 1, line, 1); this.#$.history.push(false); } } /** * @description Finds the first and last child elements in a selection area. * @param {Element} selectArea Selection area element * @returns {{ first: Node, last: Node}} Object containing the first and last child elements */ const __findFirstAndLast = function (selectArea) { const isContentLess = dom.check.isContentLess; const first = dom.query.getEdgeChild( dom.query.getEdgeChild(selectArea, (current) => !isContentLess(current), false), (current) => { return current.childNodes.length === 0 || current.nodeType === 3 || isTable(current) || isList(current); }, false, ) || selectArea.firstChild; const last = dom.query.getEdgeChild( selectArea.lastChild, (current) => { return current.childNodes.length === 0 || current.nodeType === 3 || isTable(current) || isList(current); }, true, ) || selectArea.lastChild; return { first, last }; };