UNPKG

suneditor

Version:

Vanilla JavaScript based WYSIWYG web editor

1,350 lines (1,195 loc) 76.5 kB
import { dom, converter, markdown, numbers, unicode, clipboard, env } from '../../../helper'; const { _d } = env; const REQUIRED_DATA_ATTRS = 'data-se-[^\\s]+'; const V2_MIG_DATA_ATTRS = '|data-index|data-file-size|data-file-name|data-exp|data-font-size'; /** * @description All HTML related classes involved in the editing area */ class HTML { #$; #store; #frameContext; #frameOptions; #options; #instanceCheck; #fontSizeUnitRegExp; #isAllowedClassName; #allowHTMLComment; #disallowedStyleNodesRegExp; #htmlCheckWhitelistRegExp; #htmlCheckBlacklistRegExp; #elementWhitelistRegExp; #elementBlacklistRegExp; #attributeWhitelistRegExp; #attributeBlacklistRegExp; #cleanStyleTagKeyRegExp; #cleanStyleRegExpMap; #textStyleTags; #disallowedTagsRegExp; #disallowedTagNameRegExp; #allowedTagNameRegExp; /** @type {Object<string, RegExp>} */ #attributeWhitelist; /** @type {Object<string, RegExp>} */ #attributeBlacklist; /** @type {Object<string, *>} */ #autoStyleify; /** * @constructor * @param {SunEditor.Kernel} kernel */ constructor(kernel) { this.#$ = kernel.$; this.#store = kernel.store; const options = (this.#options = this.#$.options); this.#frameOptions = this.#$.frameOptions; this.#frameContext = this.#$.frameContext; this.#instanceCheck = this.#$.instanceCheck; // members this.#isAllowedClassName = function (v) { return this.test(v) ? v : ''; }.bind(options.get('allowedClassName')); this.#textStyleTags = options.get('_textStyleTags'); // clean styles const tagStyles = options.get('tagStyles'); const splitTagStyles = {}; for (const k in tagStyles) { const s = k.split('|'); for (let i = 0, len = s.length, n; i < len; i++) { n = s[i]; if (!splitTagStyles[n]) splitTagStyles[n] = ''; else splitTagStyles[n] += '|'; splitTagStyles[n] += tagStyles[k]; } } for (const k in splitTagStyles) { splitTagStyles[k] = new RegExp(`\\s*[^-a-zA-Z](${splitTagStyles[k]})\\s*:[^;]+(?!;)*`, 'gi'); } const stylesMap = new Map(); const stylesObj = { ...splitTagStyles, line: options.get('_lineStylesRegExp'), }; this.#textStyleTags.forEach((v) => { stylesObj[v] = options.get('_textStylesRegExp'); }); for (const key in stylesObj) { stylesMap.set(new RegExp(`^(${key})$`), stylesObj[key]); } this.#cleanStyleTagKeyRegExp = new RegExp(`^(${Object.keys(stylesObj).join('|')})$`, 'i'); this.#cleanStyleRegExpMap = stylesMap; // font size unit this.#fontSizeUnitRegExp = new RegExp('\\d+(' + options.get('fontSizeUnits').join('|') + ')$', 'i'); // extra tags const allowedExtraTags = options.get('_allowedExtraTag'); const disallowedExtraTags = options.get('_disallowedExtraTag'); this.#disallowedTagsRegExp = new RegExp(`<(${disallowedExtraTags})[^>]*>([\\s\\S]*?)<\\/\\1>|<(${disallowedExtraTags})[^>]*\\/?>`, 'gi'); this.#disallowedTagNameRegExp = new RegExp(`^(${disallowedExtraTags})$`, 'i'); this.#allowedTagNameRegExp = new RegExp(`^(${allowedExtraTags})$`, 'i'); // set disallow text nodes const disallowStyleNodes = Object.keys(options.get('_defaultStyleTagMap')); const allowStyleNodes = !options.get('elementWhitelist') ? [] : options .get('elementWhitelist') .split('|') .filter((v) => /b|i|ins|s|strike/i.test(v)); for (let i = 0; i < allowStyleNodes.length; i++) { disallowStyleNodes.splice(disallowStyleNodes.indexOf(allowStyleNodes[i].toLowerCase()), 1); } this.#disallowedStyleNodesRegExp = disallowStyleNodes.length === 0 ? null : new RegExp('(<\\/?)(' + disallowStyleNodes.join('|') + ')\\b\\s*([^>^<]+)?\\s*(?=>)', 'gi'); // whitelist // tags const defaultAttr = options.get('__defaultAttributeWhitelist'); this.#allowHTMLComment = options.get('_editorElementWhitelist').includes('//') || options.get('_editorElementWhitelist') === '*'; // html check this.#htmlCheckWhitelistRegExp = new RegExp('^(' + GetRegList(options.get('_editorElementWhitelist').replace('|//', ''), '') + ')$', 'i'); this.#htmlCheckBlacklistRegExp = new RegExp('^(' + (options.get('elementBlacklist') || '^') + ')$', 'i'); // elements this.#elementWhitelistRegExp = converter.createElementWhitelist(GetRegList(options.get('_editorElementWhitelist').replace('|//', '|<!--|-->'), '')); this.#elementBlacklistRegExp = converter.createElementBlacklist(options.get('elementBlacklist').replace('|//', '|<!--|-->')); // attributes const regEndStr = '\\s*=\\s*(")[^"]*\\1'; const _wAttr = options.get('attributeWhitelist'); /** @type {Object<string, RegExp>} */ let tagsAttr = {}; let allAttr = ''; if (_wAttr) { for (const k in _wAttr) { if (/^on[a-z]+$/i.test(_wAttr[k])) continue; if (k === '*') { allAttr = GetRegList(_wAttr[k], defaultAttr); } else { tagsAttr[k] = new RegExp('\\s(?:' + GetRegList(_wAttr[k], defaultAttr) + ')' + regEndStr, 'ig'); } } } this.#attributeWhitelistRegExp = new RegExp('\\s(?:' + (allAttr || defaultAttr) + '|' + REQUIRED_DATA_ATTRS + (options.get('v2Migration') ? V2_MIG_DATA_ATTRS : '') + ')' + regEndStr, 'ig'); this.#attributeWhitelist = tagsAttr; // blacklist const _bAttr = options.get('attributeBlacklist'); tagsAttr = {}; allAttr = ''; if (_bAttr) { for (const k in _bAttr) { if (k === '*') { allAttr = GetRegList(_bAttr[k], ''); } else { tagsAttr[k] = new RegExp('\\s(?:' + GetRegList(_bAttr[k], '') + ')' + regEndStr, 'ig'); } } } this.#attributeBlacklistRegExp = new RegExp('\\s(?:' + (allAttr || '^') + ')' + regEndStr, 'ig'); this.#attributeBlacklist = tagsAttr; // autoStyleify this.__resetAutoStyleify(options.get('autoStyleify')); } /** * @description Filters an HTML string based on allowed and disallowed tags, with optional custom validation. * - Removes blacklisted tags and keeps only whitelisted tags. * - Allows custom validation functions to replace, modify, or remove elements. * @param {string} html - The HTML string to be filtered. * @param {Object} params - Filtering parameters. * @param {string} [params.tagWhitelist] - Allowed tags, specified as a string with tags separated by `'|'`. (e.g. `"div|p|span"`). * @param {string} [params.tagBlacklist] - Disallowed tags, specified as a string with tags separated by `'|'`. (e.g. `"script|iframe"`). * @param {(node: Node) => Node | string | null} [params.validate] - Function to validate and modify individual nodes. * - Return `null` to remove the node. * - Return a `Node` to replace the current node. * - Return a `string` to replace the node's `outerHTML`. * @param {boolean} [params.validateAll] - Whether to apply validation to all nodes. * @returns {string} - The filtered HTML string. * @example * // Remove script and iframe tags using blacklist * const filtered = editor.html.filter('<div>Content<script>alert("xss")</script></div>', { * tagBlacklist: 'script|iframe' * }); * * // Keep only specific tags using whitelist * const filtered = editor.html.filter('<div><span>Text</span><img src="x"></div>', { * tagWhitelist: 'div|span' * }); * * // Custom validation to modify nodes * const filtered = editor.html.filter('<div class="test"><a href="#">Link</a></div>', { * validate: (node) => { * if (node.tagName === 'A') { * node.setAttribute('target', '_blank'); * return node; * } * } * }); */ filter(html, { tagWhitelist, tagBlacklist, validate, validateAll }) { if (tagWhitelist) { html = html.replace(converter.createElementWhitelist(tagWhitelist), ''); } if (tagBlacklist) { html = html.replace(converter.createElementBlacklist(tagBlacklist), ''); } if (validate) { const parseDocument = new DOMParser().parseFromString(html, 'text/html'); parseDocument.body.querySelectorAll('*').forEach((node) => { if (validateAll || (!node.closest('.se-component') && !node.closest('.se-flex-component'))) { const result = validate(node); if (result === null) { node.remove(); } else if (this.#instanceCheck.isNode(result)) { node.replaceWith(result); } else if (typeof result === 'string') { node.outerHTML = result; } } }); html = parseDocument.body.innerHTML; } return html; } /** * @description Cleans and compresses HTML code to suit the editor format. * @param {string} html HTML string to clean and compress * @param {Object} [options] Cleaning options * @param {boolean} [options.forceFormat=false] If `true`, wraps text nodes without a format node in the format tag. * @param {?(string|RegExp)} [options.whitelist] Regular expression of allowed tags. * Create RegExp object using `helper.converter.createElementWhitelist` method. * @param {?(string|RegExp)} [options.blacklist] Regular expression of disallowed tags. * Create RegExp object using `helper.converter.createElementBlacklist` method. * @param {boolean} [options._freeCodeViewMode=false] If `true`, the free code view mode is enabled. * @returns {string} Cleaned and compressed HTML string * @example * // Basic cleaning * const cleaned = editor.html.clean('<div> <p>Hello</p> </div>'); * * // Clean with format wrapping * const cleaned = editor.html.clean('Plain text content', { forceFormat: true }); * * // Clean with blacklist to remove specific tags * const cleaned = editor.html.clean('<div><script>alert(1)</script>Content</div>', { * blacklist: 'script|style' * }); */ clean(html, { forceFormat, whitelist, blacklist, _freeCodeViewMode } = {}) { const { tagFilter, formatFilter, classFilter, textStyleTagFilter, attrFilter, styleFilter } = this.#options.get('strictMode'); let cleanData = ''; html = this.compress(html); if (tagFilter) { html = html.replace(this.#disallowedTagsRegExp, ''); html = this.#deleteDisallowedTags(html, this.#elementWhitelistRegExp, this.#elementBlacklistRegExp).replace(/<br\/?>$/i, ''); } if (this.#autoStyleify) { const domParser = new DOMParser().parseFromString(html, 'text/html'); dom.query.getListChildNodes(domParser.body, converter.spanToStyleNode.bind(null, this.#autoStyleify), null); html = domParser.body.innerHTML; } if (attrFilter || styleFilter) { html = html.replace(/(<[a-zA-Z0-9-]+)[^>]*(?=>)/g, this.#CleanElements.bind(this, attrFilter, styleFilter)); } // get dom tree const domParser = _d.createRange().createContextualFragment(html); if (tagFilter) { try { this.#consistencyCheckOfHTML(domParser, this.#htmlCheckWhitelistRegExp, this.#htmlCheckBlacklistRegExp, tagFilter, formatFilter, classFilter, _freeCodeViewMode); } catch (error) { console.warn('[SUNEDITOR.html.clean.fail]', error.message); } } // iframe placeholder parsing const iframePlaceholders = domParser.querySelectorAll('[data-se-iframe-holder]'); for (let i = 0, len = iframePlaceholders.length; i < len; i++) { /** @type {HTMLIFrameElement} */ const iframe = dom.utils.createElement('iframe'); const attrs = JSON.parse(iframePlaceholders[i].getAttribute('data-se-iframe-holder-attrs')); for (const [key, value] of Object.entries(attrs)) { // Block event handler attributes and validate src protocol if (/^on/i.test(key)) continue; if (key === 'src' && !_isSafeURL(String(value))) continue; iframe.setAttribute(key, value); } iframePlaceholders[i].replaceWith(iframe); } this.#$.pluginManager.applyRetainFormat(domParser); if (formatFilter) { let domTree = domParser.childNodes; forceFormat ||= this.#isFormatData(domTree); if (forceFormat) domTree = this.#editFormat(domParser).childNodes; for (let i = 0, len = domTree.length, t; i < len; i++) { t = domTree[i]; if (this.#allowedTagNameRegExp.test(t.nodeName)) { cleanData += /** @type {HTMLElement} */ (t).outerHTML; continue; } cleanData += this.#makeLine(t, forceFormat); } } // set clean data cleanData ||= html; // whitelist, blacklist if (tagFilter) { if (whitelist) cleanData = cleanData.replace(typeof whitelist === 'string' ? converter.createElementWhitelist(whitelist) : whitelist, ''); if (blacklist) cleanData = cleanData.replace(typeof blacklist === 'string' ? converter.createElementBlacklist(blacklist) : blacklist, ''); } if (textStyleTagFilter) { cleanData = this.#styleNodeConvertor(cleanData); } return cleanData; } /** * @description Inserts an (HTML element / HTML string / plain string) at the selection range. * - If `frameOptions.get('charCounter_max')` is exceeded when `html` is added, `null` is returned without addition. * @param {Node|string} html HTML Element or HTML string or plain string * @param {Object} [options] Options * @param {boolean} [options.selectInserted=false] If `true`, selects the range of the inserted node. * @param {boolean} [options.skipCharCount=false] If `true`, inserts even if `frameOptions.get('charCounter_max')` is exceeded. * @param {boolean} [options.skipCleaning=false] If `true`, inserts the HTML string without refining it with `html.clean`. * @returns {HTMLElement|null} The inserted element or `null` if insertion failed * @example * // Insert HTML string at cursor * editor.html.insert('<strong>Bold text</strong>'); * * // Insert and select the inserted content * editor.html.insert('<p>New paragraph</p>', { selectInserted: true }); * * // Insert raw HTML without cleaning * editor.html.insert('<div class="custom">Content</div>', { skipCleaning: true }); */ insert(html, { selectInserted, skipCharCount, skipCleaning } = {}) { if (!this.#frameContext.get('wysiwyg').contains(this.#$.selection.get().focusNode)) this.#$.focusManager.focus(); this.remove(); this.#$.focusManager.focus(); let focusNode = null; if (typeof html === 'string') { if (!skipCleaning) html = this.clean(html, { forceFormat: false, whitelist: null, blacklist: null }); try { if (dom.check.isListCell(this.#$.format.getLine(this.#$.selection.getNode(), null))) { const domParser = _d.createRange().createContextualFragment(html); const domTree = domParser.childNodes; if (this.#isFormatData(domTree)) html = this.#convertListCell(domTree); } const domParser = _d.createRange().createContextualFragment(html); const domTree = domParser.childNodes; if (!skipCharCount) { const type = this.#frameOptions.get('charCounter_type') === 'byte-html' ? 'outerHTML' : 'textContent'; let checkHTML = ''; for (let i = 0, len = domTree.length; i < len; i++) { checkHTML += domTree[i][type]; } if (!this.#$.char.check(checkHTML)) return; } let c, a, t, prev, firstCon; while ((c = domTree[0])) { if (prev?.nodeType === 3 && a?.nodeType === 1 && dom.check.isBreak(c)) { prev = c; dom.utils.removeItem(c); continue; } t = this.insertNode(c, { afterNode: a, skipCharCount: true }); a = t.container || t; firstCon ||= t; prev = c; } if (prev?.nodeType === 3 && a?.nodeType === 1) a = prev; const offset = a.nodeType === 3 ? t.endOffset || a.textContent.length : a.childNodes.length; focusNode = a; if (selectInserted) { this.#$.selection.setRange(firstCon.container || firstCon, firstCon.startOffset || 0, a, offset); } else if (!this.#$.component.is(a)) { this.#$.selection.setRange(a, offset, a, offset); } } catch (error) { if (this.#frameContext.get('isReadOnly') || this.#frameContext.get('isDisabled')) return; throw Error(`[SUNEDITOR.html.insert.error] ${error.message}`); } } else { if (this.#$.component.is(html)) { this.#$.component.insert(html, { skipCharCount, insertBehavior: 'none' }); } else { let afterNode = null; if (this.#$.format.isLine(html) || dom.check.isMedia(html)) { afterNode = this.#$.format.getLine(this.#$.selection.getNode(), null); } this.insertNode(html, { afterNode, skipCharCount }); } } // focus this.#store.set('_lastSelectionNode', null); if (focusNode) { const children = dom.query.getListChildNodes(focusNode, null, null); if (children.length > 0) { focusNode = children.at(-1); const offset = focusNode?.nodeType === 3 ? focusNode.textContent.length : 1; this.#$.selection.setRange(focusNode, offset, focusNode, offset); } else { this.#$.focusManager.focus(); } } else { this.#$.focusManager.focus(); } this.#$.history.push(false); } /** * @description Delete selected node and insert argument value node and return. * - If the `afterNode` exists, it is inserted after the `afterNode` * - Inserting a text node merges with both text nodes on both sides and returns a new `{ container, startOffset, endOffset }`. * @param {Node} oNode Node to be inserted * @param {Object} [options] Options * @param {Node} [options.afterNode=null] If the node exists, it is inserted after the node * @param {boolean} [options.skipCharCount=null] If `true`, it will be inserted even if `frameOptions.get('charCounter_max')` is exceeded. * @returns {Object|Node|null} * @example * // Insert node at current selection * const strongNode = document.createElement('strong'); * strongNode.textContent = 'Bold'; * editor.html.insertNode(strongNode); * * // Insert node after a specific element * const paragraph = editor.html.getNode(); * const newSpan = document.createElement('span'); * editor.html.insertNode(newSpan, { afterNode: paragraph }); * * // Insert bypassing character count limit * editor.html.insertNode(largeContentNode, { skipCharCount: true }); */ insertNode(oNode, { afterNode, skipCharCount } = {}) { let result = null; if (this.#frameContext.get('isReadOnly') || (!skipCharCount && !this.#$.char.check(oNode))) { return result; } let fNode = null; let range = null; if (afterNode) { const afterNewLine = this.#$.format.isLine(afterNode) || this.#$.format.isBlock(afterNode) || this.#$.component.is(afterNode) ? this.#$.format.addLine(afterNode, null) : afterNode; range = this.#$.selection.setRange(afterNewLine, 1, afterNewLine, 1); } else { range = this.#$.selection.getRange(); } let line = dom.check.isListCell(range.commonAncestorContainer) ? range.commonAncestorContainer : this.#$.format.getLine(this.#$.selection.getNode(), null); let insertListCell = dom.check.isListCell(line) && (dom.check.isListCell(oNode) || dom.check.isList(oNode)); let parentNode, originAfter, tempAfterNode, tempParentNode = null; const freeFormat = this.#$.format.isBrLine(line); const isFormats = this.#$.format.isLine(oNode) || this.#$.format.isBlock(oNode) || this.#$.component.isBasic(oNode); if (insertListCell) { tempAfterNode = afterNode || dom.check.isList(oNode) ? line.lastChild : line.nextElementSibling; tempParentNode = dom.check.isList(oNode) ? line : (tempAfterNode || line).parentNode; } if (!afterNode && (isFormats || this.#$.component.isBasic(oNode) || dom.check.isMedia(oNode))) { const isEdge = dom.check.isEdgePoint(range.endContainer, range.endOffset, 'end'); const r = this.remove(); const container = r.container; const prevContainer = container === r.prevContainer && range.collapsed ? null : r.prevContainer; if (insertListCell && prevContainer) { tempParentNode = prevContainer.nodeType === 3 ? prevContainer.parentNode : prevContainer; if (tempParentNode.contains(container)) { let sameParent = true; tempAfterNode = container; while (tempAfterNode.parentNode && tempAfterNode.parentNode !== tempParentNode) { tempAfterNode = tempAfterNode.parentNode; sameParent = false; } if (sameParent && container === prevContainer) tempAfterNode = tempAfterNode.nextSibling; } else { tempAfterNode = null; } } else if (insertListCell && dom.check.isListCell(container) && !line.parentElement) { line = dom.utils.createElement('LI'); tempParentNode.appendChild(line); container.appendChild(tempParentNode); tempAfterNode = null; } else if (container.nodeType === 3 || dom.check.isBreak(container) || insertListCell) { const depthFormat = dom.query.getParentElement(container, (current) => { return this.#$.format.isBlock(current) || dom.check.isListCell(current); }); afterNode = this.#$.nodeTransform.split(container, r.offset, !depthFormat ? 0 : dom.query.getNodeDepth(depthFormat) + 1); if (!afterNode) { if (!dom.check.isListCell(line)) { tempAfterNode = afterNode = line; } } else if (insertListCell) { if (line.contains(container)) { const subList = dom.check.isList(line.lastElementChild); let newCell = null; if (!isEdge) { newCell = line.cloneNode(false); newCell.appendChild(afterNode.textContent.trim() ? afterNode : dom.utils.createTextNode(unicode.zeroWidthSpace)); } if (subList) { if (!newCell) { newCell = line.cloneNode(false); newCell.appendChild(dom.utils.createTextNode(unicode.zeroWidthSpace)); } newCell.appendChild(line.lastElementChild); } if (newCell) { line.parentNode.insertBefore(newCell, line.nextElementSibling); tempAfterNode = afterNode = newCell; } } } else { afterNode = afterNode.previousSibling; } } } range = !afterNode && !isFormats ? this.#$.selection.getRangeAndAddLine(this.#$.selection.getRange(), null) : this.#$.selection.getRange(); const commonCon = range.commonAncestorContainer; const startOff = range.startOffset; const endOff = range.endOffset; const formatRange = range.startContainer === commonCon && this.#$.format.isLine(commonCon); const startCon = formatRange ? commonCon.childNodes[startOff] || commonCon.childNodes[0] || range.startContainer : range.startContainer; const endCon = formatRange ? commonCon.childNodes[endOff] || commonCon.childNodes[commonCon.childNodes.length - 1] || range.endContainer : range.endContainer; if (!insertListCell) { if (!afterNode) { parentNode = startCon; if (startCon.nodeType === 3) { parentNode = startCon.parentNode; } /** No Select range node */ if (range.collapsed) { if (commonCon.nodeType === 3) { if (commonCon.textContent.length > endOff) afterNode = /** @type {Text} */ (commonCon).splitText(endOff); else afterNode = commonCon.nextSibling; } else { if (!dom.check.isBreak(parentNode)) { const c = parentNode.childNodes[startOff]; const focusNode = c?.nodeType === 3 && dom.check.isZeroWidth(c) && dom.check.isBreak(c.nextSibling) ? c.nextSibling : c; if (focusNode) { if (!focusNode.nextSibling && dom.check.isBreak(focusNode)) { parentNode.removeChild(focusNode); afterNode = null; } else { afterNode = dom.check.isBreak(focusNode) && !dom.check.isBreak(oNode) ? focusNode : focusNode.nextSibling; } } else { afterNode = null; } } else { afterNode = parentNode; parentNode = parentNode.parentNode; } } } else { /** Select range nodes */ const isSameContainer = startCon === endCon; if (isSameContainer) { if (dom.check.isEdgePoint(endCon, endOff)) afterNode = endCon.nextSibling; else afterNode = /** @type {Text} */ (endCon).splitText(endOff); let removeNode = startCon; if (!dom.check.isEdgePoint(startCon, startOff)) removeNode = /** @type {Text} */ (startCon).splitText(startOff); parentNode.removeChild(removeNode); if (parentNode.childNodes.length === 0 && isFormats) { /** @type {HTMLElement} */ (parentNode).innerHTML = '<br>'; } } else { const removedTag = this.remove(); const container = removedTag.container; const prevContainer = removedTag.prevContainer; if (container?.childNodes.length === 0 && isFormats) { if (this.#$.format.isLine(container)) { container.innerHTML = '<br>'; } else if (this.#$.format.isBlock(container)) { container.innerHTML = '<' + this.#options.get('defaultLine') + '><br></' + this.#options.get('defaultLine') + '>'; } } if (dom.check.isListCell(container) && oNode.nodeType === 3) { parentNode = container; afterNode = null; } else if (!isFormats && prevContainer) { parentNode = prevContainer.nodeType === 3 ? prevContainer.parentNode : prevContainer; if (parentNode.contains(container)) { let sameParent = true; afterNode = container; while (afterNode.parentNode && afterNode.parentNode !== parentNode) { afterNode = afterNode.parentNode; sameParent = false; } if (sameParent && container === prevContainer) afterNode = afterNode.nextSibling; } else { afterNode = null; } } else if (dom.check.isWysiwygFrame(container) && !this.#$.format.isLine(oNode)) { parentNode = container.appendChild(dom.utils.createElement(this.#options.get('defaultLine'))); afterNode = null; } else { afterNode = isFormats ? endCon : container === prevContainer ? container.nextSibling : container; parentNode = !afterNode || !afterNode.parentNode ? commonCon : afterNode.parentNode; } while (afterNode && !this.#$.format.isLine(afterNode) && afterNode.parentNode !== commonCon) { afterNode = afterNode.parentNode; } } } } else { // has afterNode parentNode = afterNode.parentNode; afterNode = afterNode.nextSibling; originAfter = true; } } try { // set node const wysiwyg = this.#frameContext.get('wysiwyg'); if (!insertListCell) { if (dom.check.isWysiwygFrame(afterNode) || parentNode === wysiwyg.parentNode) { parentNode = wysiwyg; afterNode = null; } if (this.#$.format.isLine(oNode) || this.#$.format.isBlock(oNode) || (!dom.check.isListCell(parentNode) && this.#$.component.isBasic(oNode))) { const oldParent = parentNode; if (dom.check.isListCell(afterNode)) { parentNode = afterNode.previousElementSibling || afterNode; } else if (!originAfter && !afterNode) { const r = this.remove(); const container = r.container.nodeType === 3 ? (dom.check.isListCell(this.#$.format.getLine(r.container, null)) ? r.container : this.#$.format.getLine(r.container, null) || r.container.parentNode) : r.container; const rangeCon = dom.check.isWysiwygFrame(container) || this.#$.format.isBlock(container); parentNode = rangeCon ? container : container.parentNode; afterNode = rangeCon ? null : container.nextSibling; } if (oldParent.childNodes.length === 0 && parentNode !== oldParent) dom.utils.removeItem(oldParent); } if (isFormats && !freeFormat && !this.#$.format.isBlock(parentNode) && !dom.check.isListCell(parentNode) && !dom.check.isWysiwygFrame(parentNode)) { afterNode = /** @type {HTMLElement} */ (parentNode).nextElementSibling; parentNode = parentNode.parentNode; } if (dom.check.isWysiwygFrame(parentNode) && (oNode.nodeType === 3 || dom.check.isBreak(oNode))) { const formatNode = dom.utils.createElement(this.#options.get('defaultLine'), null, oNode); fNode = oNode; oNode = formatNode; } } // insert-- if (insertListCell) { if (!tempParentNode.parentNode) { parentNode = wysiwyg; afterNode = null; } else { parentNode = tempParentNode; afterNode = tempAfterNode; } } else { afterNode = parentNode === afterNode ? parentNode.lastChild : afterNode; } if (dom.check.isListCell(oNode) && !dom.check.isList(parentNode)) { if (dom.check.isListCell(parentNode)) { afterNode = parentNode.nextElementSibling; parentNode = parentNode.parentNode; } else { const ul = dom.utils.createElement('ol'); parentNode.insertBefore(ul, afterNode); parentNode = ul; afterNode = null; } insertListCell = true; } this.#checkDuplicateNode(oNode, parentNode); parentNode.insertBefore(oNode, afterNode); if (insertListCell) { if (dom.check.isZeroWidth(line.textContent.trim())) { dom.utils.removeItem(line); oNode = oNode.lastChild; } else { const chList = dom.utils.arrayFind(line.children, dom.check.isList); if (chList) { if (oNode !== chList) { oNode.appendChild(chList); oNode = chList.previousSibling; } else { parentNode.appendChild(oNode); oNode = parentNode; } if (dom.check.isZeroWidth(line.textContent.trim())) { dom.utils.removeItem(line); } } } } } catch (error) { parentNode.appendChild(oNode); console.warn('[SUNEDITOR.html.insertNode.warn]', error); } finally { if (fNode) oNode = fNode; const dupleNodes = /** @type {HTMLElement} */ (parentNode).querySelectorAll('[data-duple]'); if (dupleNodes.length > 0) { for (let i = 0, len = dupleNodes.length, d, c, ch, parent; i < len; i++) { d = dupleNodes[i]; ch = d.childNodes; parent = d.parentNode; while (ch[0]) { c = ch[0]; parent.insertBefore(c, d); } if (d === oNode) oNode = c; dom.utils.removeItem(d); } } if ((this.#$.format.isLine(oNode) || this.#$.component.isBasic(oNode)) && startCon === endCon) { const cItem = this.#$.format.getLine(commonCon, null); if (cItem?.nodeType === 1 && dom.check.isEmptyLine(cItem)) { dom.utils.removeItem(cItem); } } if (freeFormat && !dom.check.isList(oNode) && (this.#$.format.isLine(oNode) || this.#$.format.isBlock(oNode))) { oNode = this.#setIntoFreeFormat(oNode); } if (!this.#$.component.isBasic(oNode)) { let offset = 1; if (oNode.nodeType === 3) { offset = oNode.textContent.length; this.#$.selection.setRange(oNode, offset, oNode, offset); } else if (!dom.check.isBreak(oNode) && !dom.check.isListCell(oNode) && this.#$.format.isLine(parentNode)) { let zeroWidth = null; if (!oNode.previousSibling || dom.check.isBreak(oNode.previousSibling)) { zeroWidth = dom.utils.createTextNode(unicode.zeroWidthSpace); oNode.parentNode.insertBefore(zeroWidth, oNode); } if (!oNode.nextSibling || dom.check.isBreak(oNode.nextSibling)) { zeroWidth = dom.utils.createTextNode(unicode.zeroWidthSpace); oNode.parentNode.insertBefore(zeroWidth, oNode.nextSibling); } if (this.#$.inline._isIgnoreNodeChange(oNode)) { oNode = oNode.nextSibling; offset = 0; } this.#$.selection.setRange(oNode, offset, oNode, offset); } } result = oNode; } return result; } /** * @description Delete the selected range. * @returns {{container: Node, offset: number, commonCon?: ?Node, prevContainer?: ?Node}} * - `container`: the last element after deletion * - `offset`: offset * - `commonCon`: `commonAncestorContainer` * - `prevContainer`: `previousElementSibling` of the deleted area */ remove() { this.#$.selection.resetRangeToTextNode(); const range = this.#$.selection.getRange(); const isStartEdge = range.startOffset === 0; const isEndEdge = dom.check.isEdgePoint(range.endContainer, range.endOffset, 'end'); let prevContainer = null; let startPrevEl = null; let endNextEl = null; if (isStartEdge) { startPrevEl = this.#$.format.getLine(range.startContainer); prevContainer = startPrevEl ? startPrevEl.previousElementSibling : null; startPrevEl = startPrevEl ? prevContainer : startPrevEl; } if (isEndEdge) { endNextEl = this.#$.format.getLine(range.endContainer); endNextEl = endNextEl ? endNextEl.nextElementSibling : endNextEl; } let container, offset = 0; let startCon = range.startContainer; let endCon = range.endContainer; let startOff = range.startOffset; let endOff = range.endOffset; const commonCon = /** @type {HTMLElement} */ (range.commonAncestorContainer.nodeType === 3 && range.commonAncestorContainer.parentNode === startCon.parentNode ? startCon.parentNode : range.commonAncestorContainer); if (dom.check.isWysiwygFrame(startCon) && dom.check.isWysiwygFrame(endCon)) { this.set(''); const newInitBR = this.#$.selection.getNode(); return { container: newInitBR, offset: 0, commonCon, }; } if (commonCon === startCon && commonCon === endCon) { if (this.#$.component.isBasic(commonCon)) { const compInfo = this.#$.component.get(commonCon); const compContainer = compInfo.container; const parent = compContainer.parentElement; const next = compContainer.nextSibling || compContainer.previousSibling; const nextOffset = next === compContainer.previousSibling ? next?.textContent?.length || 1 : 0; const parentNext = parent.nextElementSibling || parent.previousElementSibling; const parentNextOffset = parentNext === parent.previousElementSibling ? parentNext?.textContent?.length || 1 : 0; dom.utils.removeItem(compContainer); if (this.#$.format.isLine(parent)) { if (parent.childNodes.length === 0) { dom.utils.removeItem(parent); return { container: parentNext, offset: parentNextOffset, commonCon, }; } else { return { container: next, offset: nextOffset, commonCon, }; } } else { return { container: parentNext, offset: parentNextOffset, commonCon, }; } } else { if ((commonCon.nodeType === 1 && startOff === 0 && endOff === 1) || (commonCon.nodeType === 3 && startOff === 0 && endOff === commonCon.textContent.length)) { const nextEl = dom.query.getNextDeepestNode(commonCon, this.#frameContext.get('wysiwyg')); const prevEl = dom.query.getPreviousDeepestNode(commonCon, this.#frameContext.get('wysiwyg')); const line = this.#$.format.getLine(commonCon); dom.utils.removeItem(commonCon); let rEl = nextEl || prevEl; let rOffset = nextEl ? 0 : rEl?.nodeType === 3 ? rEl.textContent.length : 1; const npEl = this.#$.format.getLine(rEl) || this.#$.component.get(rEl); if (line !== npEl) { rEl = /** @type {Node} */ (npEl); rOffset = rOffset === 0 ? 0 : 1; } if (dom.check.isZeroWidth(line) && !line.contains(rEl)) { dom.utils.removeItem(line); } return { container: rEl, offset: rOffset, commonCon, }; } startCon = commonCon.children[startOff]; endCon = commonCon.children[endOff]; startOff = endOff = 0; } } if (!startCon || !endCon) return { container: commonCon, offset: 0, commonCon, }; if (startCon === endCon && range.collapsed) { if (dom.check.isZeroWidth(startCon.textContent?.substring(startOff))) { return { container: startCon, offset: startOff, prevContainer: startCon && startCon.parentNode ? startCon : null, commonCon, }; } } let beforeNode = null; let afterNode = null; const childNodes = dom.query.getListChildNodes(commonCon, null, null); let startIndex = dom.utils.getArrayIndex(childNodes, startCon); let endIndex = dom.utils.getArrayIndex(childNodes, endCon); if (childNodes.length > 0 && startIndex > -1 && endIndex > -1) { for (let i = startIndex + 1, startNode = startCon; i >= 0; i--) { if (childNodes[i] === startNode.parentNode && childNodes[i].firstChild === startNode && startOff === 0) { startIndex = i; startNode = startNode.parentNode; } } for (let i = endIndex - 1, endNode = endCon; i > startIndex; i--) { if (childNodes[i] === endNode.parentNode && childNodes[i].nodeType === 1) { childNodes.splice(i, 1); endNode = endNode.parentNode; --endIndex; } } } else { if (childNodes.length === 0) { if (this.#$.format.isLine(commonCon) || this.#$.format.isBlock(commonCon) || dom.check.isWysiwygFrame(commonCon) || dom.check.isBreak(commonCon) || dom.check.isMedia(commonCon)) { return { container: commonCon, offset: 0, commonCon, }; } else if (dom.check.isText(commonCon)) { return { container: commonCon, offset: endOff, commonCon, }; } childNodes.push(commonCon); startCon = endCon = commonCon; } else { startCon = endCon = childNodes[0]; if (dom.check.isBreak(startCon) || dom.check.isZeroWidth(startCon)) { return { container: dom.check.isMedia(commonCon) ? commonCon : startCon, offset: 0, commonCon, }; } } startIndex = endIndex = 0; } const _isText = dom.check.isText; const _isElement = dom.check.isElement; const _isSingleItem = startIndex === endIndex; let nextFocusNodes = null; for (let i = startIndex; i <= endIndex; i++) { const item = /** @type {Text} */ (childNodes[i]); if (_isText(item) && (item.data === undefined || item.length === 0)) { nextFocusNodes = this.#nodeRemoveListItem(item, _isSingleItem); continue; } if (item === startCon) { if (_isElement(startCon)) { if (this.#$.component.is(startCon)) continue; else beforeNode = dom.utils.createTextNode(startCon.textContent); } else { const sc = /** @type {Text} */ (startCon); const ec = /** @type {Text} */ (endCon); if (item === endCon) { beforeNode = dom.utils.createTextNode(sc.substringData(0, startOff) + ec.substringData(endOff, ec.length - endOff)); offset = startOff; } else { beforeNode = dom.utils.createTextNode(sc.substringData(0, startOff)); } } if (beforeNode.length > 0) { /** @type {Text} */ (startCon).data = beforeNode.data; } else { nextFocusNodes = this.#nodeRemoveListItem(startCon, _isSingleItem); } if (item === endCon) break; continue; } if (item === endCon) { if (_isText(endCon)) { afterNode = dom.utils.createTextNode(endCon.substringData(endOff, endCon.length - endOff)); } else { if (this.#$.component.is(endCon)) continue; else afterNode = dom.utils.createTextNode(endCon.textContent); } if (afterNode.length > 0) { /** @type {Text} */ (endCon).data = afterNode.data; } else { nextFocusNodes = this.#nodeRemoveListItem(endCon, _isSingleItem); } continue; } nextFocusNodes = this.#nodeRemoveListItem(item, _isSingleItem); } const endUl = dom.query.getParentElement(endCon, 'ul'); const startLi = dom.query.getParentElement(startCon, 'li'); if (endUl && startLi && startLi.contains(endUl)) { container = endUl.previousSibling; offset = container.textContent.length; } else { container = endCon && endCon.parentNode ? endCon : startCon && startCon.parentNode ? startCon : range.endContainer || range.startContainer; if (isStartEdge || isEndEdge) { if (isEndEdge) { if (container.nodeType === 1 && container.childNodes.length === 0) { container.appendChild(dom.utils.createElement('BR')); offset = 1; } else { offset = container.textContent.length; } } else { offset = 0; } } } if (!this.#$.format.getLine(container) && !(startCon && startCon.parentNode)) { if (endNextEl) { container = endNextEl; offset = 0; } else if (startPrevEl) { container = startPrevEl; offset = 1; } } if (!dom.check.isWysiwygFrame(container) && container.childNodes.length === 0) { const rc = this.#$.nodeTransform.removeAllParents(container, null, null); if (rc) container = rc.sc || rc.ec || this.#frameContext.get('wysiwyg'); } if (!container || (container.nodeType === 1 && !this.#$.format.isLine(container) && !dom.check.isBreak(container))) { container = nextFocusNodes?.sc || nextFocusNodes?.ec; offset = container?.nodeType === 3 ? container.textContent.length : 1; } // set range this.#$.selection.setRange(container, offset, container, offset); return { container, offset, prevContainer, commonCon: commonCon?.parentElement ? commonCon : null, }; } /** * @description Gets the current content * @param {Object} [options] Options * @param {boolean} [options.withFrame=false] Gets the current content with containing parent `div.sun-editor-editable` (`<div class="sun-editor-editable">{content}</div>`). * Ignored for `targetOptions.get('iframe_fullPage')` is `true`. * @param {boolean} [options.includeFullPage=false] Return only the content of the body without headers when the `iframe_fullPage` option is `true` * @param {number|Array<number>} [options.rootKey=null] Root index * @returns {string|Object<*, string>} * @example * const html = editor.$.html.get(); * const htmlWithFrame = editor.$.html.get({ withFrame: true }); */ get({ withFrame, includeFullPage, rootKey } = {}) { if (!rootKey) rootKey = [this.#store.get('rootKey')]; else if (!Array.isArray(rootKey)) rootKey = [rootKey]; const prevrootKey = this.#store.get('rootKey'); const resultValue = {}; for (let i = 0, len = rootKey.length, r; i < len; i++) { this.#$.facade.changeFrameContext(rootKey[i]); const renderHTML = dom.utils.createElement('DIV', null, this._convertToCode(this.#frameContext.get('wysiwyg'), true)); const isTableCell = dom.check.isTableCell; const isEmptyLine = dom.check.isEmptyLine; const editableEls = []; const emptyCells = []; dom.query.getListChildren( renderHTML, (current) => { if (current.hasAttribute('contenteditable')) { editableEls.push(current); } const parent = current.parentElement; if (isTableCell(parent) && parent.children.length <= 1 && isEmptyLine(current)) { emptyCells.push(parent); } return false; }, null, ); for (let j = 0, jlen = editableEls.length; j < jlen; j++) { editableEls[j].removeAttribute('contenteditable'); } for (let j = 0, jlen = emptyCells.length; j < jlen; j++) { emptyCells[j].innerHTML = '<br>'; } // output: wrap code blocks <pre class="language-xxx"> → <pre><code class="language-xxx"> this.#wrapPreCode(renderHTML); const content = renderHTML.innerHTML; if (this.#frameOptions.get('iframe_fullPage')) { if (includeFullPage) { const attrs = dom.utils.getAttributesToString(this.#frameContext.get('_wd').body, ['contenteditable']); r = `<!DOCTYPE html><html>${this.#frameContext.get('_wd').head.outerHTML}<body ${attrs}>${content}</body></html>`; } else { r = content; } } else { r = withFrame ? `<div class="${this.#options.get('_editableClass') + '' + (this.#options.get('_rtl') ? ' se-rtl' : '')}">${content}</div>` : renderHTML.innerHTML; } resultValue[rootKey[i]] = r; } this.#$.facade.changeFrameContext(prevrootKey); return rootKey.length > 1 ? resultValue : resultValue[rootKey[0]]; } /** * @description Sets the HTML string to the editor content * @param {string} html HTML string * @param {Object} [options] Options * @param {number|Array<number>} [options.rootKey=null] Root index * @example * editor.$.html.set('<p>New content</p>'); * editor.$.html.set(html, { rootKey: 'header' }); */ set(html, { rootKey } = {}) { this.#$.ui.offCurrentController(); this.#$.selection.removeRange(); const convertValue = html === null || html === undefined ? '' : this.clean(html, { forceFormat: true, whitelist: null, blacklist: null }); if (!rootKey) rootKey = [this.#store.get('rootKey')]; else if (!Array.isArray(rootKey)) rootKey = [rootKey]; for (let i = 0; i < rootKey.length; i++) { this.#$.facade.changeFrameContext(rootKey[i]); if (this.#frameContext.get('isMarkdownView')) { const json = converter.htmlToJson(convertValue); this.#frameContext.get('markdown').value = markdown.jsonToMarkdown(json); } else if (!this.#frameContext.get('isCodeView')) { this.#frameContext.get('wysiwyg').innerHTML = convertValue; this.#$.pluginManager.resetFileInfo(); this.#$.history.push(false, rootKey[i]); } else { const value = this._convertToCode(convertValue, false); this.#$.viewer._setCodeView(value); } } } /** * @description Add content to the end of content. * @param {string} html Content to Input * @param {Object} [options] Options * @param {number|Array<number>} [options.rootKey=null] Root index */ add(html, { rootKey } = {}) { this.#$.ui.offCurrentController(); if (!rootKey) rootKey = [this.#store.get('rootKey')]; else if (!Array.isArray(rootKey)) rootKey = [rootKey]; for (let i = 0; i < rootKey.length; i++) { this.#$.facade.changeFrameContext(rootKey[i]); const convertValue = this.clean(html, { forceFormat: true, whitelist: null, blacklist: null }); if (this.#frameContext.get('isMarkdownView')) { const json = converter.htmlToJson(convertValue); this.#frameContext.get('markdown').value += '\n' + markdown.jsonToMarkdown(json); } else if (!this.#frameContext.get('isCodeView')) { const temp = dom.utils.createElement('DIV', null, convertValue); const children = Array.from(temp.children); for (let j = 0, jLen = children.length; j < jLen; j++) { this.#frameContext.get('wysiwyg').appendChild(children[j]); } this.#$.history.push(false, rootKey[i]); this.#$.selection.scrollTo(children.at(-1)); } else { this.#$.viewer._setCodeView(this.#$.viewer._getCodeView() + '\n' + this._convertToCode(convertValue, false)); } } } /** * @description Gets the current content to JSON data * @param {Object} [options] Options * @param {boolean} [options.withFrame=false] Gets the current content with containing parent `div.sun-editor-editable` (`<div class="sun-editor-editable">{content}</div>`). * @param {number|Array<number>} [options.rootKey=null] Root index * @returns {Object<string, *>} JSON data * @example * const json = editor.$.html.getJson(); * const jsonWithFrame = editor.$.html.getJson({ withFrame: true }); */ getJson({ withFrame, rootKey } = {}) { return converter.htmlToJson(this.get({ withFrame, rootKey })); } /** * @description Sets the JSON data to the editor content * (see @link converter.jsonToHtml) * @param {Object<string, *>} jsdonData HTML string * @param {Object} [options] Options * @param {number|Array<number>} [options.rootKey=null] Root index * @example * const html = editor.$.html.setJson({ * type: 'element', tag: 'p', attributes: { class: 'txt' }, * children: [{ type: 'text', content: 'Hello' }], * }); * // '<p class="txt">Hello</p>' */ setJson(jsdonData, { rootKey } = {}) { this.set(converter.jsonToHtml(jsdonData), { rootKey }); } /** * @description Call `clipboard.write` to copy the contents and display a success/failure toast message. * @param {Node|Element|Text|string} content Content to be copied to the clipboard * @returns {Promise<boolean>} Success or failure */ async copy(content) { try { if (typeof content !== 'string' && !dom.check.isElement(content) && !dom.check.isText(content)) return false; if ((await clipboard.write(content)) === false) { this.#$.ui.showToast(this.#$.lang.message_copy_fail, this.#options.get('toastMessageTime').copy, 'error'); return false; } this.#$.ui.showToast(this.#$.lang.message_copy_success, this.#options.get('toastMessageTime').copy); return true; } catch (err) { console.error('[SUNEDITOR.html.copy.fail] :', err); this.#$.ui.showToast(this.#$.lang.message_copy_fail, this.#options.get('toastMessageTime').copy, 'error'); return false; } } /** * @description Sets the content of the iframe's head tag and body tag when using the `iframe` or `iframe_fullPage` option. * @param {{head: string, body: string}} ctx { head: HTML string, body: HTML string} * @param {Object} [options] Options * @param {number|Array<number>} [options.rootKey=null] Root index */ setFullPage(ctx, { rootKey } = {}) { if (!this.#frameOptions.get('iframe')) return false; if (!rootKey) rootKey = [this.#store.get('rootKey')]; else if (!Array.isArray(rootKey)) rootKey = [rootKey]; for (let i = 0; i < rootKey.length; i++) { this.#$.facade.changeFrameContext(rootKey[i]); if (ctx.head) this.#frameContext.get('_wd').head.innerHTML = ctx.head.replace(this.#disallowedTagsRegExp, ''); if (ctx.body) this.#frameContext.get('_wd').body.innerHTML = this.clean(ctx.body, { forceFormat: true, whitelist: null, blacklist: null }); this.#$.pluginManager.resetFileInfo(); } } /** * @description HTML code compression * @param {string} html HTML string * @returns {string} HTML string */ compress(html) { return html.replace(/>\s+</g, '> <').replace(/\n/g, '').trim(); } /** * @internal * @description construct wysiwyg area element to html string * @param {Node|string} html WYSIWYG element (this.#frameContext.get('wysiwyg')) or HTML string. * @param {boolean} comp If `true`, does not line break and indentation of tags. * @returns {string} */ _convertToCode(html, comp) { let returnHTML = ''; const wRegExp = RegExp; const brReg = new wRegExp('^(BLOCKQUOTE|PRE|TABLE|THEAD|TBODY|TR|TH|TD|OL|UL|IMG|IFRAME|VIDEO|AUDIO|FIGURE|FIGCAPTION|HR|BR|CANVAS|SELECT)$', 'i'); const wDoc = typeof html === 'string' ? _d.createRange().createContextualFragment(html) : html; const isFormat = (current) => { return this.#$.format.isLine(current) || this.#$.component.is(current); }; const brChar = comp ? '' : '\n'; const codeSize = comp ? 0 : this.#store.get('codeIndentSize') * 1; const indentSize = codeSize > 0 ? new Array(codeSize + 1).join(' ') : ''; (function recursionFunc(element, indent) { const children = element?.childNodes; if (!children) return; const elementRegTest = brReg.test(element.nodeName); const elementIndent = elementRegTest ? indent : ''; for (let i = 0, len = children.length, node, br, lineBR, nodeRegTest, tag, tagI