UNPKG

jodit

Version:

Jodit is awesome and usefully wysiwyg editor with filebrowser

560 lines (470 loc) 12.8 kB
/*! * Jodit Editor (https://xdsoft.net/jodit/) * Released under MIT see LICENSE.txt in the project root for license information. * Copyright (c) 2013-2020 Valeriy Chupurnov. All rights reserved. https://xdsoft.net */ import { Config } from '../../config'; import { INVISIBLE_SPACE, INVISIBLE_SPACE_REG_EXP, INVISIBLE_SPACE_REG_EXP as INV_REG, SPACE_REG_EXP, IS_INLINE } from '../../core/constants'; import { Dom } from '../../modules'; import { isString, normalizeNode, trim } from '../../core/helpers'; import { HTMLTagNames, IDictionary, IJodit, Nullable } from '../../types'; import { Plugin } from '../../core/plugin'; /** * @property {object} cleanHTML {@link cleanHtml|cleanHtml}'s options * @property {boolean} cleanHTML.cleanOnPaste=true clean pasted html * @property {boolean} cleanHTML.replaceNBSP=true Replace &amp;nbsp; toWYSIWYG plain space * @property {boolean} cleanHTML.allowTags=false The allowTags option defines which elements will remain in the * edited text when the editor saves. You can use this toWYSIWYG limit the returned HTML toWYSIWYG a subset. * @example * ```javascript * var jodit = new Jodit('#editor', { * cleanHTML: { * cleanOnPaste: false * } * }); * ``` * @example * ```javascript * var editor = Jodit('#editor', { * cleanHTML: { * allowTags: 'p,a[href],table,tr,td, img[src=1.png]' // allow only <p>,<a>,<table>,<tr>,<td>,<img> tags and * for <a> allow only `href` attribute and <img> allow only `src` atrribute == '1.png' * } * }); * editor.value = 'Sorry! <strong>Goodby</strong>\ * <span>mr.</span> <a style="color:red" href="http://xdsoft.net">Freeman</a>'; * console.log(editor.value); //Sorry! <a href="http://xdsoft.net">Freeman</a> * ``` * * @example * ```javascript * var editor = Jodit('#editor', { * cleanHTML: { * allowTags: { * p: true, * a: { * href: true * }, * table: true, * tr: true, * td: true, * img: { * src: '1.png' * } * } * } * }); * ``` */ declare module '../../config' { interface Config { cleanHTML: { timeout: number; replaceNBSP: boolean; fillEmptyParagraph: boolean; removeEmptyElements: boolean; replaceOldTags: IDictionary<HTMLTagNames> | false; allowTags: false | string | IDictionary<string>; denyTags: false | string | IDictionary<string>; }; } } Config.prototype.cleanHTML = { timeout: 300, removeEmptyElements: true, fillEmptyParagraph: true, replaceNBSP: true, replaceOldTags: { i: 'em', b: 'strong' }, allowTags: false, denyTags: false }; Config.prototype.controls.eraser = { command: 'removeFormat', tooltip: 'Clear Formatting' }; /** * Clean HTML after removeFormat and insertHorizontalRule command */ export class cleanHtml extends Plugin { protected afterInit(jodit: IJodit): void { jodit.e .off('.cleanHtml') .on( 'change.cleanHtml afterSetMode.cleanHtml afterInit.cleanHtml mousedown.cleanHtml keydown.cleanHtml', jodit.async.debounce(this.onChange, jodit.o.cleanHTML.timeout) ) .on('keyup.cleanHtml', this.onKeyUpCleanUp) .on('beforeCommand.cleanHtml', this.beforeCommand) .on('afterCommand.cleanHtml', this.afterCommand); } private onChange = () => { if (!this.allowEdit()) { return; } const editor = this.j; const current = editor.s.current(); const replaceOldTags = editor.o.cleanHTML.replaceOldTags; if (replaceOldTags && current) { const tags = Object.keys(replaceOldTags) as HTMLTagNames[]; if (editor.s.isCollapsed()) { const oldParent = Dom.closest(current, tags, editor.editor); if (oldParent) { const selInfo = editor.s.save(), tagName: string = replaceOldTags[oldParent.nodeName.toLowerCase()] || replaceOldTags[oldParent.nodeName]; Dom.replace( oldParent as HTMLElement, tagName as HTMLTagNames, editor.createInside, true, false ); editor.s.restore(selInfo); } } } let node: Node | null = null; if (editor.editor.firstChild) { node = editor.editor.firstChild as Element; } const remove: Node[] = []; const work = this.checkNode(node, current, remove); remove.forEach(Dom.safeRemove); if (remove.length || work) { editor.events && editor.e.fire('syncho'); } }; private allowEdit(): boolean { return !( this.j.isInDestruct || !this.j.isEditorMode() || this.j.getReadOnly() ); } private checkNode = ( nodeElm: Nullable<Element | Node>, current: Nullable<Node>, remove: Node[] ): boolean => { let work = false; if (!nodeElm) { return work; } if (this.isRemovableNode(nodeElm, current)) { remove.push(nodeElm); return this.checkNode(nodeElm.nextSibling, current, remove); } if ( this.j.o.cleanHTML.fillEmptyParagraph && Dom.isBlock(nodeElm, this.j.ew) && Dom.isEmpty(nodeElm, /^(img|svg|canvas|input|textarea|form|br)$/) ) { const br = this.j.createInside.element('br'); nodeElm.appendChild(br); work = true; } const allow = this.allowTagsHash; if (allow && allow[nodeElm.nodeName] !== true) { const attrs: NamedNodeMap = (nodeElm as Element).attributes; if (attrs && attrs.length) { const removeAttrs: string[] = []; for (let i = 0; i < attrs.length; i += 1) { const attr = allow[nodeElm.nodeName][attrs[i].name]; if (!attr || (attr !== true && attr !== attrs[i].value)) { removeAttrs.push(attrs[i].name); } } if (removeAttrs.length) { work = true; } removeAttrs.forEach(attr => { (nodeElm as Element).removeAttribute(attr); }); } } work = this.checkNode(nodeElm.firstChild, current, remove) || work; work = this.checkNode(nodeElm.nextSibling, current, remove) || work; return work; }; private static getHash( tags: false | string | IDictionary<string> ): IDictionary | false { const attributesReg = /([^[]*)\[([^\]]+)]/; const seperator = /[\s]*,[\s]*/, attrReg = /^(.*)[\s]*=[\s]*(.*)$/; const tagsHash: IDictionary = {}; if (isString(tags)) { tags.split(seperator).map((elm: string) => { elm = trim(elm); const attr: RegExpExecArray | null = attributesReg.exec(elm), allowAttributes: IDictionary<string | boolean> = {}, attributeMap = (attrName: string) => { attrName = trim(attrName); const val: string[] | null = attrReg.exec(attrName); if (val) { allowAttributes[val[1]] = val[2]; } else { allowAttributes[attrName] = true; } }; if (attr) { const attr2: string[] = attr[2].split(seperator); if (attr[1]) { attr2.forEach(attributeMap); tagsHash[attr[1].toUpperCase()] = allowAttributes; } } else { tagsHash[elm.toUpperCase()] = true; } }); return tagsHash; } if (tags) { Object.keys(tags).forEach(tagName => { tagsHash[tagName.toUpperCase()] = tags[tagName]; }); return tagsHash; } return false; } private allowTagsHash: IDictionary | false = cleanHtml.getHash( this.j.o.cleanHTML.allowTags ); private denyTagsHash: IDictionary | false = cleanHtml.getHash( this.j.o.cleanHTML.denyTags ); // remove invisible chars if node has another chars private onKeyUpCleanUp = () => { const editor = this.j; if (!this.allowEdit()) { return; } const currentNode = editor.s.current(); if (currentNode) { const currentParagraph = Dom.up( currentNode, node => Dom.isBlock(node, editor.ew), editor.editor ); if (currentParagraph) { Dom.all(currentParagraph, node => { if (node && Dom.isText(node)) { if ( node.nodeValue !== null && INV_REG().test(node.nodeValue) && node.nodeValue.replace(INV_REG(), '').length !== 0 ) { node.nodeValue = node.nodeValue.replace( INV_REG(), '' ); if ( node === currentNode && editor.s.isCollapsed() ) { editor.s.setCursorAfter(node); } } } }); } } }; private beforeCommand = (command: string): void | false => { if (command.toLowerCase() === 'removeformat') { this.onRemoveFormat(); return false; } }; private afterCommand = (command: string) => { if (command.toLowerCase() === 'inserthorizontalrule') { this.onInsertHorizontalLine(); return; } }; private onInsertHorizontalLine() { const hr: HTMLHRElement | null = this.j.editor.querySelector( 'hr[id=null]' ); if (hr) { let node = Dom.next( hr, node => Dom.isBlock(node, this.j.ew), this.j.editor, false ) as Node | null; if (!node) { node = this.j.createInside.element(this.j.o.enter); if (node) { Dom.after(hr, node as HTMLElement); } } this.j.s.setCursorIn(node); } } private onRemoveFormat() { const sel = this.j.selection; const current = sel.current(); if (!current) { return; } const up = (node: Node | null) => node && Dom.up(node, Dom.isInlineBlock, this.j.editor); let parentNode = up(current), anotherParent = parentNode; while (anotherParent) { anotherParent = up(anotherParent.parentNode); if (anotherParent) { parentNode = anotherParent; } } const collapsed = sel.isCollapsed(); const range = sel.range; let fragment: DocumentFragment | null = null; if (!collapsed) { fragment = range.extractContents(); } if (parentNode) { const tmp = this.j.createInside.text(INVISIBLE_SPACE); range.insertNode(tmp); const insideParent = Dom.isOrContains(parentNode, tmp, true); Dom.safeRemove(tmp); range.collapse(true); if ( insideParent && parentNode.parentNode && parentNode.parentNode !== fragment ) { const second = this.j.s.splitSelection( parentNode as HTMLElement ); this.j.s.setCursorAfter(second || parentNode); if (Dom.isEmpty(parentNode)) { Dom.safeRemove(parentNode); } } } if (fragment) { sel.insertNode(this.cleanFragment(fragment)); } } private cleanFragment(fragment: Node): Node { Dom.each(fragment, node => { if (Dom.isElement(node) && IS_INLINE.test(node.nodeName)) { this.cleanFragment(node); Dom.unwrap(node); } }); return fragment; } /** * @deprecated * @param elm * @param onlyRemoveFont */ private cleanNode = ( elm: Node, onlyRemoveFont: boolean = false ): false | void => { switch (elm.nodeType) { case Node.ELEMENT_NODE: Dom.each(elm, child => { this.cleanNode(child, onlyRemoveFont); }); if (Dom.isTag(elm, 'font')) { Dom.unwrap(elm); } else if (!onlyRemoveFont) { // clean some "style" attributes in selected range Array.from((elm as Element).attributes).forEach( (attr: Attr) => { if ( ['src', 'href', 'rel', 'content'].indexOf( attr.name.toLowerCase() ) === -1 ) { (elm as Element).removeAttribute(attr.name); } } ); normalizeNode(elm); } break; case Node.TEXT_NODE: if ( !onlyRemoveFont && this.j.o.cleanHTML.replaceNBSP && Dom.isText(elm) && elm.nodeValue !== null && elm.nodeValue.match(SPACE_REG_EXP()) ) { elm.nodeValue = elm.nodeValue .replace(INVISIBLE_SPACE_REG_EXP(), '') .replace(SPACE_REG_EXP(), ' '); } break; default: Dom.safeRemove(elm); } }; private isRemovableNode(node: Node, current: Nullable<Node>): boolean { const allow = this.allowTagsHash; if ( !Dom.isText(node) && ((allow && !allow[node.nodeName]) || (this.denyTagsHash && this.denyTagsHash[node.nodeName])) ) { return true; } // remove extra br if ( current && Dom.isTag(node, 'br') && cleanHtml.hasNotEmptyTextSibling(node) && !cleanHtml.hasNotEmptyTextSibling(node, true) && Dom.up( node, node => Dom.isBlock(node, this.j.ew), this.j.editor ) !== Dom.up( current, node => Dom.isBlock(node, this.j.ew), this.j.editor ) ) { return true; } return ( this.j.o.cleanHTML.removeEmptyElements && current !== null && Dom.isElement(node) && node.nodeName.match(IS_INLINE) !== null && !this.j.s.isMarker(node) && trim((node as Element).innerHTML).length === 0 && !Dom.isOrContains(node, current) ); } private static hasNotEmptyTextSibling(node: Node, next = false): boolean { let prev: Node | null = next ? node.nextSibling : node.previousSibling; while (prev) { if (Dom.isElement(prev) || !Dom.isEmptyTextNode(prev)) { return true; } prev = next ? prev.nextSibling : prev.previousSibling; } return false; } protected beforeDestruct(): void { this.j.e.off('.cleanHtml'); } }