Mercurial Hosting > sceditor
diff src/sceditor.js @ 4:b7725dab7482
move /development/* to /
author | Franklin Schmidt <fschmidt@gmail.com> |
---|---|
date | Thu, 04 Aug 2022 17:59:02 -0600 |
parents | src/development/sceditor.js@4c4fc447baea |
children | c26f7240e96b |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/sceditor.js Thu Aug 04 17:59:02 2022 -0600 @@ -0,0 +1,9089 @@ +(function () { + 'use strict'; + + /** + * Check if the passed argument is the + * the passed type. + * + * @param {string} type + * @param {*} arg + * @returns {boolean} + */ + function isTypeof(type, arg) { + return typeof arg === type; + } + + /** + * @type {function(*): boolean} + */ + var isString = isTypeof.bind(null, 'string'); + + /** + * @type {function(*): boolean} + */ + var isUndefined = isTypeof.bind(null, 'undefined'); + + /** + * @type {function(*): boolean} + */ + var isFunction = isTypeof.bind(null, 'function'); + + /** + * @type {function(*): boolean} + */ + var isNumber = isTypeof.bind(null, 'number'); + + + /** + * Returns true if an object has no keys + * + * @param {!Object} obj + * @returns {boolean} + */ + function isEmptyObject(obj) { + return !Object.keys(obj).length; + } + + /** + * Extends the first object with any extra objects passed + * + * If the first argument is boolean and set to true + * it will extend child arrays and objects recursively. + * + * @param {!Object|boolean} targetArg + * @param {...Object} source + * @return {Object} + */ + function extend(targetArg, sourceArg) { + var isTargetBoolean = targetArg === !!targetArg; + var i = isTargetBoolean ? 2 : 1; + var target = isTargetBoolean ? sourceArg : targetArg; + var isDeep = isTargetBoolean ? targetArg : false; + + function isObject(value) { + return value !== null && typeof value === 'object' && + Object.getPrototypeOf(value) === Object.prototype; + } + + for (; i < arguments.length; i++) { + var source = arguments[i]; + + // Copy all properties for jQuery compatibility + /* eslint guard-for-in: off */ + for (var key in source) { + var targetValue = target[key]; + var value = source[key]; + + // Skip undefined values to match jQuery + if (isUndefined(value)) { + continue; + } + + // Skip special keys to prevent prototype pollution + if (key === '__proto__' || key === 'constructor') { + continue; + } + + var isValueObject = isObject(value); + var isValueArray = Array.isArray(value); + + if (isDeep && (isValueObject || isValueArray)) { + // Can only merge if target type matches otherwise create + // new target to merge into + var isSameType = isObject(targetValue) === isValueObject && + Array.isArray(targetValue) === isValueArray; + + target[key] = extend( + true, + isSameType ? targetValue : (isValueArray ? [] : {}), + value + ); + } else { + target[key] = value; + } + } + } + + return target; + } + + /** + * Removes an item from the passed array + * + * @param {!Array} arr + * @param {*} item + */ + function arrayRemove(arr, item) { + var i = arr.indexOf(item); + + if (i > -1) { + arr.splice(i, 1); + } + } + + /** + * Iterates over an array or object + * + * @param {!Object|Array} obj + * @param {function(*, *)} fn + */ + function each(obj, fn) { + if (Array.isArray(obj) || 'length' in obj && isNumber(obj.length)) { + for (var i = 0; i < obj.length; i++) { + fn(i, obj[i]); + } + } else { + Object.keys(obj).forEach(function (key) { + fn(key, obj[key]); + }); + } + } + + /** + * Cache of camelCase CSS property names + * @type {Object<string, string>} + */ + var cssPropertyNameCache = {}; + + /** + * Node type constant for element nodes + * + * @type {number} + */ + var ELEMENT_NODE = 1; + + /** + * Node type constant for text nodes + * + * @type {number} + */ + var TEXT_NODE = 3; + + /** + * Node type constant for comment nodes + * + * @type {number} + */ + var COMMENT_NODE = 8; + + function toFloat(value) { + value = parseFloat(value); + + return isFinite(value) ? value : 0; + } + + /** + * Creates an element with the specified attributes + * + * Will create it in the current document unless context + * is specified. + * + * @param {!string} tag + * @param {!Object<string, string>} [attributes] + * @param {!Document} [context] + * @returns {!HTMLElement} + */ + function createElement(tag, attributes, context) { + var node = (context || document).createElement(tag); + + each(attributes || {}, function (key, value) { + if (key === 'style') { + node.style.cssText = value; + } else if (key in node) { + node[key] = value; + } else { + node.setAttribute(key, value); + } + }); + + return node; + } + + /** + * Gets the first parent node that matches the selector + * + * @param {!HTMLElement} node + * @param {!string} [selector] + * @returns {HTMLElement|undefined} + */ + function parent(node, selector) { + var parent = node || {}; + + while ((parent = parent.parentNode) && !/(9|11)/.test(parent.nodeType)) { + if (!selector || is(parent, selector)) { + return parent; + } + } + } + + /** + * Checks the passed node and all parents and + * returns the first matching node if any. + * + * @param {!HTMLElement} node + * @param {!string} selector + * @returns {HTMLElement|undefined} + */ + function closest(node, selector) { + return is(node, selector) ? node : parent(node, selector); + } + + /** + * Removes the node from the DOM + * + * @param {!HTMLElement} node + */ + function remove(node) { + if (node.parentNode) { + node.parentNode.removeChild(node); + } + } + + /** + * Appends child to parent node + * + * @param {!HTMLElement} node + * @param {!HTMLElement} child + */ + function appendChild(node, child) { + node.appendChild(child); + } + + /** + * Finds any child nodes that match the selector + * + * @param {!HTMLElement} node + * @param {!string} selector + * @returns {NodeList} + */ + function find(node, selector) { + return node.querySelectorAll(selector); + } + + /** + * For on() and off() if to add/remove the event + * to the capture phase + * + * @type {boolean} + */ + var EVENT_CAPTURE = true; + + /** + * Adds an event listener for the specified events. + * + * Events should be a space separated list of events. + * + * If selector is specified the handler will only be + * called when the event target matches the selector. + * + * @param {!Node} node + * @param {string} events + * @param {string} [selector] + * @param {function(Object)} fn + * @param {boolean} [capture=false] + * @see off() + */ + // eslint-disable-next-line max-params + function on(node, events, selector, fn, capture) { + events.split(' ').forEach(function (event) { + var handler; + + if (isString(selector)) { + handler = fn['_sce-event-' + event + selector] || function (e) { + var target = e.target; + while (target && target !== node) { + if (is(target, selector)) { + fn.call(target, e); + return; + } + + target = target.parentNode; + } + }; + + fn['_sce-event-' + event + selector] = handler; + } else { + handler = selector; + capture = fn; + } + + node.addEventListener(event, handler, capture || false); + }); + } + + /** + * Removes an event listener for the specified events. + * + * @param {!Node} node + * @param {string} events + * @param {string} [selector] + * @param {function(Object)} fn + * @param {boolean} [capture=false] + * @see on() + */ + // eslint-disable-next-line max-params + function off(node, events, selector, fn, capture) { + events.split(' ').forEach(function (event) { + var handler; + + if (isString(selector)) { + handler = fn['_sce-event-' + event + selector]; + } else { + handler = selector; + capture = fn; + } + + node.removeEventListener(event, handler, capture || false); + }); + } + + /** + * If only attr param is specified it will get + * the value of the attr param. + * + * If value is specified but null the attribute + * will be removed otherwise the attr value will + * be set to the passed value. + * + * @param {!HTMLElement} node + * @param {!string} attr + * @param {?string} [value] + */ + function attr(node, attr, value) { + if (arguments.length < 3) { + return node.getAttribute(attr); + } + + // eslint-disable-next-line eqeqeq, no-eq-null + if (value == null) { + removeAttr(node, attr); + } else { + node.setAttribute(attr, value); + } + } + + /** + * Removes the specified attribute + * + * @param {!HTMLElement} node + * @param {!string} attr + */ + function removeAttr(node, attr) { + node.removeAttribute(attr); + } + + /** + * Sets the passed elements display to none + * + * @param {!HTMLElement} node + */ + function hide(node) { + css(node, 'display', 'none'); + } + + /** + * Sets the passed elements display to default + * + * @param {!HTMLElement} node + */ + function show(node) { + css(node, 'display', ''); + } + + /** + * Toggles an elements visibility + * + * @param {!HTMLElement} node + */ + function toggle(node) { + if (isVisible(node)) { + hide(node); + } else { + show(node); + } + } + + /** + * Gets a computed CSS values or sets an inline CSS value + * + * Rules should be in camelCase format and not + * hyphenated like CSS properties. + * + * @param {!HTMLElement} node + * @param {!Object|string} rule + * @param {string|number} [value] + * @return {string|number|undefined} + */ + function css(node, rule, value) { + if (arguments.length < 3) { + if (isString(rule)) { + return node.nodeType === 1 ? getComputedStyle(node)[rule] : null; + } + + each(rule, function (key, value) { + css(node, key, value); + }); + } else { + // isNaN returns false for null, false and empty strings + // so need to check it's truthy or 0 + var isNumeric = (value || value === 0) && !isNaN(value); + node.style[rule] = isNumeric ? value + 'px' : value; + } + } + + + /** + * Gets or sets the data attributes on a node + * + * Unlike the jQuery version this only stores data + * in the DOM attributes which means only strings + * can be stored. + * + * @param {Node} node + * @param {string} [key] + * @param {string} [value] + * @return {Object|undefined} + */ + function data(node, key, value) { + var argsLength = arguments.length; + var data = {}; + + if (node.nodeType === ELEMENT_NODE) { + if (argsLength === 1) { + each(node.attributes, function (_, attr) { + if (/^data\-/i.test(attr.name)) { + data[attr.name.substr(5)] = attr.value; + } + }); + + return data; + } + + if (argsLength === 2) { + return attr(node, 'data-' + key); + } + + attr(node, 'data-' + key, String(value)); + } + } + + /** + * Checks if node matches the given selector. + * + * @param {?HTMLElement} node + * @param {string} selector + * @returns {boolean} + */ + function is(node, selector) { + var result = false; + + if (node && node.nodeType === ELEMENT_NODE) { + result = (node.matches || node.msMatchesSelector || + node.webkitMatchesSelector).call(node, selector); + } + + return result; + } + + + /** + * Returns true if node contains child otherwise false. + * + * This differs from the DOM contains() method in that + * if node and child are equal this will return false. + * + * @param {!Node} node + * @param {HTMLElement} child + * @returns {boolean} + */ + function contains(node, child) { + return node !== child && node.contains && node.contains(child); + } + + /** + * @param {Node} node + * @param {string} [selector] + * @returns {?HTMLElement} + */ + function previousElementSibling(node, selector) { + var prev = node.previousElementSibling; + + if (selector && prev) { + return is(prev, selector) ? prev : null; + } + + return prev; + } + + /** + * @param {!Node} node + * @param {!Node} refNode + * @returns {Node} + */ + function insertBefore(node, refNode) { + return refNode.parentNode.insertBefore(node, refNode); + } + + /** + * @param {?HTMLElement} node + * @returns {!Array.<string>} + */ + function classes(node) { + return node.className.trim().split(/\s+/); + } + + /** + * @param {?HTMLElement} node + * @param {string} className + * @returns {boolean} + */ + function hasClass(node, className) { + return is(node, '.' + className); + } + + /** + * @param {!HTMLElement} node + * @param {string} className + */ + function addClass(node, className) { + var classList = classes(node); + + if (classList.indexOf(className) < 0) { + classList.push(className); + } + + node.className = classList.join(' '); + } + + /** + * @param {!HTMLElement} node + * @param {string} className + */ + function removeClass(node, className) { + var classList = classes(node); + + arrayRemove(classList, className); + + node.className = classList.join(' '); + } + + /** + * Toggles a class on node. + * + * If state is specified and is truthy it will add + * the class. + * + * If state is specified and is falsey it will remove + * the class. + * + * @param {HTMLElement} node + * @param {string} className + * @param {boolean} [state] + */ + function toggleClass(node, className, state) { + state = isUndefined(state) ? !hasClass(node, className) : state; + + if (state) { + addClass(node, className); + } else { + removeClass(node, className); + } + } + + /** + * Gets or sets the width of the passed node. + * + * @param {HTMLElement} node + * @param {number|string} [value] + * @returns {number|undefined} + */ + function width(node, value) { + if (isUndefined(value)) { + var cs = getComputedStyle(node); + var padding = toFloat(cs.paddingLeft) + toFloat(cs.paddingRight); + var border = toFloat(cs.borderLeftWidth) + toFloat(cs.borderRightWidth); + + return node.offsetWidth - padding - border; + } + + css(node, 'width', value); + } + + /** + * Gets or sets the height of the passed node. + * + * @param {HTMLElement} node + * @param {number|string} [value] + * @returns {number|undefined} + */ + function height(node, value) { + if (isUndefined(value)) { + var cs = getComputedStyle(node); + var padding = toFloat(cs.paddingTop) + toFloat(cs.paddingBottom); + var border = toFloat(cs.borderTopWidth) + toFloat(cs.borderBottomWidth); + + return node.offsetHeight - padding - border; + } + + css(node, 'height', value); + } + + /** + * Triggers a custom event with the specified name and + * sets the detail property to the data object passed. + * + * @param {HTMLElement} node + * @param {string} eventName + * @param {Object} [data] + */ + function trigger(node, eventName, data) { + var event; + + if (isFunction(window.CustomEvent)) { + event = new CustomEvent(eventName, { + bubbles: true, + cancelable: true, + detail: data + }); + } else { + event = node.ownerDocument.createEvent('CustomEvent'); + event.initCustomEvent(eventName, true, true, data); + } + + node.dispatchEvent(event); + } + + /** + * Returns if a node is visible. + * + * @param {HTMLElement} + * @returns {boolean} + */ + function isVisible(node) { + return !!node.getClientRects().length; + } + + /** + * Convert CSS property names into camel case + * + * @param {string} string + * @returns {string} + */ + function camelCase(string) { + return string + .replace(/^-ms-/, 'ms-') + .replace(/-(\w)/g, function (match, char) { + return char.toUpperCase(); + }); + } + + + /** + * Loop all child nodes of the passed node + * + * The function should accept 1 parameter being the node. + * If the function returns false the loop will be exited. + * + * @param {HTMLElement} node + * @param {function} func Callback which is called with every + * child node as the first argument. + * @param {boolean} innermostFirst If the innermost node should be passed + * to the function before it's parents. + * @param {boolean} siblingsOnly If to only traverse the nodes siblings + * @param {boolean} [reverse=false] If to traverse the nodes in reverse + */ + // eslint-disable-next-line max-params + function traverse(node, func, innermostFirst, siblingsOnly, reverse) { + node = reverse ? node.lastChild : node.firstChild; + + while (node) { + var next = reverse ? node.previousSibling : node.nextSibling; + + if ( + (!innermostFirst && func(node) === false) || + (!siblingsOnly && traverse( + node, func, innermostFirst, siblingsOnly, reverse + ) === false) || + (innermostFirst && func(node) === false) + ) { + return false; + } + + node = next; + } + } + + /** + * Like traverse but loops in reverse + * @see traverse + */ + function rTraverse(node, func, innermostFirst, siblingsOnly) { + traverse(node, func, innermostFirst, siblingsOnly, true); + } + + /** + * Parses HTML into a document fragment + * + * @param {string} html + * @param {Document} [context] + * @since 1.4.4 + * @return {DocumentFragment} + */ + function parseHTML(html, context) { + context = context || document; + + var ret = context.createDocumentFragment(); + var tmp = createElement('div', {}, context); + + tmp.innerHTML = html; + + while (tmp.firstChild) { + appendChild(ret, tmp.firstChild); + } + + return ret; + } + + /** + * Checks if an element has any styling. + * + * It has styling if it is not a plain <div> or <p> or + * if it has a class, style attribute or data. + * + * @param {HTMLElement} elm + * @return {boolean} + * @since 1.4.4 + */ + function hasStyling(node) { + return node && (!is(node, 'p,div') || node.className || + attr(node, 'style') || !isEmptyObject(data(node))); + } + + /** + * Converts an element from one type to another. + * + * For example it can convert the element <b> to <strong> + * + * @param {HTMLElement} element + * @param {string} toTagName + * @return {HTMLElement} + * @since 1.4.4 + */ + function convertElement(element, toTagName) { + var newElement = createElement(toTagName, {}, element.ownerDocument); + + each(element.attributes, function (_, attribute) { + // Some browsers parse invalid attributes names like + // 'size"2' which throw an exception when set, just + // ignore these. + try { + attr(newElement, attribute.name, attribute.value); + } catch (ex) {} + }); + + while (element.firstChild) { + appendChild(newElement, element.firstChild); + } + + element.parentNode.replaceChild(newElement, element); + + return newElement; + } + + /** + * List of block level elements separated by bars (|) + * + * @type {string} + */ + var blockLevelList = '|body|hr|p|div|h1|h2|h3|h4|h5|h6|address|pre|' + + 'form|table|tbody|thead|tfoot|th|tr|td|li|ol|ul|blockquote|center|' + + 'details|section|article|aside|nav|main|header|hgroup|footer|fieldset|' + + 'dl|dt|dd|figure|figcaption|'; + + /** + * List of elements that do not allow children separated by bars (|) + * + * @param {Node} node + * @return {boolean} + * @since 1.4.5 + */ + function canHaveChildren(node) { + // 1 = Element + // 9 = Document + // 11 = Document Fragment + if (!/11?|9/.test(node.nodeType)) { + return false; + } + + // List of empty HTML tags separated by bar (|) character. + // Source: http://www.w3.org/TR/html4/index/elements.html + // Source: http://www.w3.org/TR/html5/syntax.html#void-elements + return ('|iframe|area|base|basefont|br|col|frame|hr|img|input|wbr' + + '|isindex|link|meta|param|command|embed|keygen|source|track|' + + 'object|').indexOf('|' + node.nodeName.toLowerCase() + '|') < 0; + } + + /** + * Checks if an element is inline + * + * @param {HTMLElement} elm + * @param {boolean} [includeCodeAsBlock=false] + * @return {boolean} + */ + function isInline(elm, includeCodeAsBlock) { + var tagName, + nodeType = (elm || {}).nodeType || TEXT_NODE; + + if (nodeType !== ELEMENT_NODE) { + return nodeType === TEXT_NODE; + } + + tagName = elm.tagName.toLowerCase(); + + if (tagName === 'code') { + return !includeCodeAsBlock; + } + + return blockLevelList.indexOf('|' + tagName + '|') < 0; + } + + /** + * Copy the CSS from 1 node to another. + * + * Only copies CSS defined on the element e.g. style attr. + * + * @param {HTMLElement} from + * @param {HTMLElement} to + * @deprecated since v3.1.0 + */ + function copyCSS(from, to) { + if (to.style && from.style) { + to.style.cssText = from.style.cssText + to.style.cssText; + } + } + + /** + * Checks if a DOM node is empty + * + * @param {Node} node + * @returns {boolean} + */ + function isEmpty(node) { + if (node.lastChild && isEmpty(node.lastChild)) { + remove(node.lastChild); + } + + return node.nodeType === 3 ? !node.nodeValue : + (canHaveChildren(node) && !node.childNodes.length); + } + + /** + * Fixes block level elements inside in inline elements. + * + * Also fixes invalid list nesting by placing nested lists + * inside the previous li tag or wrapping them in an li tag. + * + * @param {HTMLElement} node + */ + function fixNesting(node) { + traverse(node, function (node) { + var list = 'ul,ol', + isBlock = !isInline(node, true) && node.nodeType !== COMMENT_NODE, + parent = node.parentNode; + + // Any blocklevel element inside an inline element needs fixing. + // Also <p> tags that contain blocks should be fixed + if (isBlock && (isInline(parent, true) || parent.tagName === 'P')) { + // Find the last inline parent node + var lastInlineParent = node; + while (isInline(lastInlineParent.parentNode, true) || + lastInlineParent.parentNode.tagName === 'P') { + lastInlineParent = lastInlineParent.parentNode; + } + + var before = extractContents(lastInlineParent, node); + var middle = node; + + // Clone inline styling and apply it to the blocks children + while (parent && isInline(parent, true)) { + if (parent.nodeType === ELEMENT_NODE) { + var clone = parent.cloneNode(); + while (middle.firstChild) { + appendChild(clone, middle.firstChild); + } + + appendChild(middle, clone); + } + parent = parent.parentNode; + } + + insertBefore(middle, lastInlineParent); + if (!isEmpty(before)) { + insertBefore(before, middle); + } + if (isEmpty(lastInlineParent)) { + remove(lastInlineParent); + } + } + + // Fix invalid nested lists which should be wrapped in an li tag + if (isBlock && is(node, list) && is(node.parentNode, list)) { + var li = previousElementSibling(node, 'li'); + + if (!li) { + li = createElement('li'); + insertBefore(li, node); + } + + appendChild(li, node); + } + }); + } + + /** + * Finds the common parent of two nodes + * + * @param {!HTMLElement} node1 + * @param {!HTMLElement} node2 + * @return {?HTMLElement} + */ + function findCommonAncestor(node1, node2) { + while ((node1 = node1.parentNode)) { + if (contains(node1, node2)) { + return node1; + } + } + } + + /** + * @param {?Node} + * @param {boolean} [previous=false] + * @returns {?Node} + */ + function getSibling(node, previous) { + if (!node) { + return null; + } + + return (previous ? node.previousSibling : node.nextSibling) || + getSibling(node.parentNode, previous); + } + + /** + * Removes unused whitespace from the root and all it's children. + * + * @param {!HTMLElement} root + * @since 1.4.3 + */ + function removeWhiteSpace(root) { + var nodeValue, nodeType, next, previous, previousSibling, + nextNode, trimStart, + cssWhiteSpace = css(root, 'whiteSpace'), + // Preserve newlines if is pre-line + preserveNewLines = /line$/i.test(cssWhiteSpace), + node = root.firstChild; + + // Skip pre & pre-wrap with any vendor prefix + if (/pre(\-wrap)?$/i.test(cssWhiteSpace)) { + return; + } + + while (node) { + nextNode = node.nextSibling; + nodeValue = node.nodeValue; + nodeType = node.nodeType; + + if (nodeType === ELEMENT_NODE && node.firstChild) { + removeWhiteSpace(node); + } + + if (nodeType === TEXT_NODE) { + next = getSibling(node); + previous = getSibling(node, true); + trimStart = false; + + while (hasClass(previous, 'sceditor-ignore')) { + previous = getSibling(previous, true); + } + + // If previous sibling isn't inline or is a textnode that + // ends in whitespace, time the start whitespace + if (isInline(node) && previous) { + previousSibling = previous; + + while (previousSibling.lastChild) { + previousSibling = previousSibling.lastChild; + + // eslint-disable-next-line max-depth + while (hasClass(previousSibling, 'sceditor-ignore')) { + previousSibling = getSibling(previousSibling, true); + } + } + + trimStart = previousSibling.nodeType === TEXT_NODE ? + /[\t\n\r ]$/.test(previousSibling.nodeValue) : + !isInline(previousSibling); + } + + // Clear zero width spaces + nodeValue = nodeValue.replace(/\u200B/g, ''); + + // Strip leading whitespace + if (!previous || !isInline(previous) || trimStart) { + nodeValue = nodeValue.replace( + preserveNewLines ? /^[\t ]+/ : /^[\t\n\r ]+/, + '' + ); + } + + // Strip trailing whitespace + if (!next || !isInline(next)) { + nodeValue = nodeValue.replace( + preserveNewLines ? /[\t ]+$/ : /[\t\n\r ]+$/, + '' + ); + } + + // Remove empty text nodes + if (!nodeValue.length) { + remove(node); + } else { + node.nodeValue = nodeValue.replace( + preserveNewLines ? /[\t ]+/g : /[\t\n\r ]+/g, + ' ' + ); + } + } + + node = nextNode; + } + } + + /** + * Extracts all the nodes between the start and end nodes + * + * @param {HTMLElement} startNode The node to start extracting at + * @param {HTMLElement} endNode The node to stop extracting at + * @return {DocumentFragment} + */ + function extractContents(startNode, endNode) { + var range = startNode.ownerDocument.createRange(); + + range.setStartBefore(startNode); + range.setEndAfter(endNode); + + return range.extractContents(); + } + + /** + * Gets the offset position of an element + * + * @param {HTMLElement} node + * @return {Object} An object with left and top properties + */ + function getOffset(node) { + var left = 0, + top = 0; + + while (node) { + left += node.offsetLeft; + top += node.offsetTop; + node = node.offsetParent; + } + + return { + left: left, + top: top + }; + } + + /** + * Gets the value of a CSS property from the elements style attribute + * + * @param {HTMLElement} elm + * @param {string} property + * @return {string} + */ + function getStyle(elm, property) { + var styleValue, + elmStyle = elm.style; + + if (!cssPropertyNameCache[property]) { + cssPropertyNameCache[property] = camelCase(property); + } + + property = cssPropertyNameCache[property]; + styleValue = elmStyle[property]; + + // Add an exception for text-align + if ('textAlign' === property) { + styleValue = styleValue || css(elm, property); + + if (css(elm.parentNode, property) === styleValue || + css(elm, 'display') !== 'block' || is(elm, 'hr,th')) { + return ''; + } + } + + return styleValue; + } + + /** + * Tests if an element has a style. + * + * If values are specified it will check that the styles value + * matches one of the values + * + * @param {HTMLElement} elm + * @param {string} property + * @param {string|array} [values] + * @return {boolean} + */ + function hasStyle(elm, property, values) { + var styleValue = getStyle(elm, property); + + if (!styleValue) { + return false; + } + + return !values || styleValue === values || + (Array.isArray(values) && values.indexOf(styleValue) > -1); + } + + /** + * Returns true if both nodes have the same number of inline styles and all the + * inline styles have matching values + * + * @param {HTMLElement} nodeA + * @param {HTMLElement} nodeB + * @returns {boolean} + */ + function stylesMatch(nodeA, nodeB) { + var i = nodeA.style.length; + if (i !== nodeB.style.length) { + return false; + } + + while (i--) { + var prop = nodeA.style[i]; + if (nodeA.style[prop] !== nodeB.style[prop]) { + return false; + } + } + + return true; + } + + /** + * Returns true if both nodes have the same number of attributes and all the + * attribute values match + * + * @param {HTMLElement} nodeA + * @param {HTMLElement} nodeB + * @returns {boolean} + */ + function attributesMatch(nodeA, nodeB) { + var i = nodeA.attributes.length; + if (i !== nodeB.attributes.length) { + return false; + } + + while (i--) { + var prop = nodeA.attributes[i]; + var notMatches = prop.name === 'style' ? + !stylesMatch(nodeA, nodeB) : + prop.value !== attr(nodeB, prop.name); + + if (notMatches) { + return false; + } + } + + return true; + } + + /** + * Removes an element placing its children in its place + * + * @param {HTMLElement} node + */ + function removeKeepChildren(node) { + while (node.firstChild) { + insertBefore(node.firstChild, node); + } + + remove(node); + } + + /** + * Merges inline styles and tags with parents where possible + * + * @param {Node} node + * @since 3.1.0 + */ + function merge(node) { + if (node.nodeType !== ELEMENT_NODE) { + return; + } + + var parent = node.parentNode; + var tagName = node.tagName; + var mergeTags = /B|STRONG|EM|SPAN|FONT/; + + // Merge children (in reverse as children can be removed) + var i = node.childNodes.length; + while (i--) { + merge(node.childNodes[i]); + } + + // Should only merge inline tags + if (!isInline(node)) { + return; + } + + // Remove any inline styles that match the parent style + i = node.style.length; + while (i--) { + var prop = node.style[i]; + if (css(parent, prop) === css(node, prop)) { + node.style.removeProperty(prop); + } + } + + // Can only remove / merge tags if no inline styling left. + // If there is any inline style left then it means it at least partially + // doesn't match the parent style so must stay + if (!node.style.length) { + removeAttr(node, 'style'); + + // Remove font attributes if match parent + if (tagName === 'FONT') { + if (css(node, 'fontFamily').toLowerCase() === + css(parent, 'fontFamily').toLowerCase()) { + removeAttr(node, 'face'); + } + + if (css(node, 'color') === css(parent, 'color')) { + removeAttr(node, 'color'); + } + + if (css(node, 'fontSize') === css(parent, 'fontSize')) { + removeAttr(node, 'size'); + } + } + + // Spans and font tags with no attributes can be safely removed + if (!node.attributes.length && /SPAN|FONT/.test(tagName)) { + removeKeepChildren(node); + } else if (mergeTags.test(tagName)) { + var isBold = /B|STRONG/.test(tagName); + var isItalic = tagName === 'EM'; + + while (parent && isInline(parent) && + (!isBold || /bold|700/i.test(css(parent, 'fontWeight'))) && + (!isItalic || css(parent, 'fontStyle') === 'italic')) { + + // Remove if parent match + if ((parent.tagName === tagName || + (isBold && /B|STRONG/.test(parent.tagName))) && + attributesMatch(parent, node)) { + removeKeepChildren(node); + break; + } + + parent = parent.parentNode; + } + } + } + + // Merge siblings if attributes, including inline styles, match + var next = node.nextSibling; + if (next && next.tagName === tagName && attributesMatch(next, node)) { + appendChild(node, next); + removeKeepChildren(next); + } + } + + /** + * Default options for SCEditor + * @type {Object} + */ + var defaultOptions = { + /** @lends jQuery.sceditor.defaultOptions */ + /** + * Toolbar buttons order and groups. Should be comma separated and + * have a bar | to separate groups + * + * @type {string} + */ + toolbar: 'bold,italic,underline,strike,subscript,superscript|' + + 'left,center,right,justify|font,size,color,removeformat|' + + 'cut,copy,pastetext|bulletlist,orderedlist,indent,outdent|' + + 'table|code,quote|horizontalrule,image,email,link,unlink|' + + 'emoticon,youtube,date,time|ltr,rtl|print,maximize,source', + + /** + * Comma separated list of commands to excludes from the toolbar + * + * @type {string} + */ + toolbarExclude: null, + + /** + * Stylesheet to include in the WYSIWYG editor. This is what will style + * the WYSIWYG elements + * + * @type {string} + */ + style: 'jquery.sceditor.default.css', + + /** + * Comma separated list of fonts for the font selector + * + * @type {string} + */ + fonts: 'Arial,Arial Black,Comic Sans MS,Courier New,Georgia,Impact,' + + 'Sans-serif,Serif,Times New Roman,Trebuchet MS,Verdana', + + /** + * Colors should be comma separated and have a bar | to signal a new + * column. + * + * If null the colors will be auto generated. + * + * @type {string} + */ + colors: '#000000,#44B8FF,#1E92F7,#0074D9,#005DC2,#00369B,#b3d5f4|' + + '#444444,#C3FFFF,#9DF9FF,#7FDBFF,#68C4E8,#419DC1,#d9f4ff|' + + '#666666,#72FF84,#4CEA5E,#2ECC40,#17B529,#008E02,#c0f0c6|' + + '#888888,#FFFF44,#FFFA1E,#FFDC00,#E8C500,#C19E00,#fff5b3|' + + '#aaaaaa,#FFC95F,#FFA339,#FF851B,#E86E04,#C14700,#ffdbbb|' + + '#cccccc,#FF857A,#FF5F54,#FF4136,#E82A1F,#C10300,#ffc6c3|' + + '#eeeeee,#FF56FF,#FF30DC,#F012BE,#D900A7,#B20080,#fbb8ec|' + + '#ffffff,#F551FF,#CF2BE7,#B10DC9,#9A00B2,#9A00B2,#e8b6ef', + + /** + * The locale to use. + * @type {string} + */ + locale: attr(document.documentElement, 'lang') || 'en', + + /** + * The Charset to use + * @type {string} + */ + charset: 'utf-8', + + /** + * Compatibility mode for emoticons. + * + * Helps if you have emoticons such as :/ which would put an emoticon + * inside http:// + * + * This mode requires emoticons to be surrounded by whitespace or end of + * line chars. This mode has limited As You Type emoticon conversion + * support. It will not replace AYT for end of line chars, only + * emoticons surrounded by whitespace. They will still be replaced + * correctly when loaded just not AYT. + * + * @type {boolean} + */ + emoticonsCompat: false, + + /** + * If to enable emoticons. Can be changes at runtime using the + * emoticons() method. + * + * @type {boolean} + * @since 1.4.2 + */ + emoticonsEnabled: true, + + /** + * Emoticon root URL + * + * @type {string} + */ + emoticonsRoot: '', + emoticons: { + dropdown: { + ':)': 'emoticons/smile.png', + ':angel:': 'emoticons/angel.png', + ':angry:': 'emoticons/angry.png', + '8-)': 'emoticons/cool.png', + ':\'(': 'emoticons/cwy.png', + ':ermm:': 'emoticons/ermm.png', + ':D': 'emoticons/grin.png', + '<3': 'emoticons/heart.png', + ':(': 'emoticons/sad.png', + ':O': 'emoticons/shocked.png', + ':P': 'emoticons/tongue.png', + ';)': 'emoticons/wink.png' + }, + more: { + ':alien:': 'emoticons/alien.png', + ':blink:': 'emoticons/blink.png', + ':blush:': 'emoticons/blush.png', + ':cheerful:': 'emoticons/cheerful.png', + ':devil:': 'emoticons/devil.png', + ':dizzy:': 'emoticons/dizzy.png', + ':getlost:': 'emoticons/getlost.png', + ':happy:': 'emoticons/happy.png', + ':kissing:': 'emoticons/kissing.png', + ':ninja:': 'emoticons/ninja.png', + ':pinch:': 'emoticons/pinch.png', + ':pouty:': 'emoticons/pouty.png', + ':sick:': 'emoticons/sick.png', + ':sideways:': 'emoticons/sideways.png', + ':silly:': 'emoticons/silly.png', + ':sleeping:': 'emoticons/sleeping.png', + ':unsure:': 'emoticons/unsure.png', + ':woot:': 'emoticons/w00t.png', + ':wassat:': 'emoticons/wassat.png' + }, + hidden: { + ':whistling:': 'emoticons/whistling.png', + ':love:': 'emoticons/wub.png' + } + }, + + /** + * Width of the editor. Set to null for automatic with + * + * @type {?number} + */ + width: null, + + /** + * Height of the editor including toolbar. Set to null for automatic + * height + * + * @type {?number} + */ + height: null, + + /** + * If to allow the editor to be resized + * + * @type {boolean} + */ + resizeEnabled: true, + + /** + * Min resize to width, set to null for half textarea width or -1 for + * unlimited + * + * @type {?number} + */ + resizeMinWidth: null, + /** + * Min resize to height, set to null for half textarea height or -1 for + * unlimited + * + * @type {?number} + */ + resizeMinHeight: null, + /** + * Max resize to height, set to null for double textarea height or -1 + * for unlimited + * + * @type {?number} + */ + resizeMaxHeight: null, + /** + * Max resize to width, set to null for double textarea width or -1 for + * unlimited + * + * @type {?number} + */ + resizeMaxWidth: null, + /** + * If resizing by height is enabled + * + * @type {boolean} + */ + resizeHeight: true, + /** + * If resizing by width is enabled + * + * @type {boolean} + */ + resizeWidth: true, + + /** + * Date format, will be overridden if locale specifies one. + * + * The words year, month and day will be replaced with the users current + * year, month and day. + * + * @type {string} + */ + dateFormat: 'year-month-day', + + /** + * Element to inset the toolbar into. + * + * @type {HTMLElement} + */ + toolbarContainer: null, + + /** + * If to enable paste filtering. This is currently experimental, please + * report any issues. + * + * @type {boolean} + */ + enablePasteFiltering: false, + + /** + * If to completely disable pasting into the editor + * + * @type {boolean} + */ + disablePasting: false, + + /** + * If the editor is read only. + * + * @type {boolean} + */ + readOnly: false, + + /** + * If to set the editor to right-to-left mode. + * + * If set to null the direction will be automatically detected. + * + * @type {boolean} + */ + rtl: false, + + /** + * If to auto focus the editor on page load + * + * @type {boolean} + */ + autofocus: false, + + /** + * If to auto focus the editor to the end of the content + * + * @type {boolean} + */ + autofocusEnd: true, + + /** + * If to auto expand the editor to fix the content + * + * @type {boolean} + */ + autoExpand: false, + + /** + * If to auto update original textbox on blur + * + * @type {boolean} + */ + autoUpdate: false, + + /** + * If to enable the browsers built in spell checker + * + * @type {boolean} + */ + spellcheck: true, + + /** + * If to run the source editor when there is no WYSIWYG support. Only + * really applies to mobile OS's. + * + * @type {boolean} + */ + runWithoutWysiwygSupport: false, + + /** + * If to load the editor in source mode and still allow switching + * between WYSIWYG and source mode + * + * @type {boolean} + */ + startInSourceMode: false, + + /** + * Optional ID to give the editor. + * + * @type {string} + */ + id: null, + + /** + * Comma separated list of plugins + * + * @type {string} + */ + plugins: '', + + /** + * z-index to set the editor container to. Needed for jQuery UI dialog. + * + * @type {?number} + */ + zIndex: null, + + /** + * If to trim the BBCode. Removes any spaces at the start and end of the + * BBCode string. + * + * @type {boolean} + */ + bbcodeTrim: false, + + /** + * If to disable removing block level elements by pressing backspace at + * the start of them + * + * @type {boolean} + */ + disableBlockRemove: false, + + /** + * Array of allowed URL (should be either strings or regex) for iframes. + * + * If it's a string then iframes where the start of the src matches the + * specified string will be allowed. + * + * If it's a regex then iframes where the src matches the regex will be + * allowed. + * + * @type {Array} + */ + allowedIframeUrls: [], + + /** + * BBCode parser options, only applies if using the editor in BBCode + * mode. + * + * See SCEditor.BBCodeParser.defaults for list of valid options + * + * @type {Object} + */ + parserOptions: { }, + + /** + * CSS that will be added to the to dropdown menu (eg. z-index) + * + * @type {Object} + */ + dropDownCss: { } + }; + + // Must start with a valid scheme + // ^ + // Schemes that are considered safe + // (https?|s?ftp|mailto|spotify|skype|ssh|teamspeak|tel):| + // Relative schemes (//:) are considered safe + // (\\/\\/)| + // Image data URI's are considered safe + // data:image\\/(png|bmp|gif|p?jpe?g); + var VALID_SCHEME_REGEX = + /^(https?|s?ftp|mailto|spotify|skype|ssh|teamspeak|tel):|(\/\/)|data:image\/(png|bmp|gif|p?jpe?g);/i; + + /** + * Escapes a string so it's safe to use in regex + * + * @param {string} str + * @return {string} + */ + function regex(str) { + return str.replace(/([\-.*+?^=!:${}()|\[\]\/\\])/g, '\\$1'); + } + /** + * Escapes all HTML entities in a string + * + * If noQuotes is set to false, all single and double + * quotes will also be escaped + * + * @param {string} str + * @param {boolean} [noQuotes=true] + * @return {string} + * @since 1.4.1 + */ + function entities(str, noQuotes) { + if (!str) { + return str; + } + + var replacements = { + '&': '&', + '<': '<', + '>': '>', + ' ': ' ', + '\r\n': '<br />', + '\r': '<br />', + '\n': '<br />' + }; + + if (noQuotes !== false) { + replacements['"'] = '"'; + replacements['\''] = '''; + replacements['`'] = '`'; + } + + str = str.replace(/ {2}|\r\n|[&<>\r\n'"`]/g, function (match) { + return replacements[match] || match; + }); + + return str; + } + /** + * Escape URI scheme. + * + * Appends the current URL to a url if it has a scheme that is not: + * + * http + * https + * sftp + * ftp + * mailto + * spotify + * skype + * ssh + * teamspeak + * tel + * // + * data:image/(png|jpeg|jpg|pjpeg|bmp|gif); + * + * **IMPORTANT**: This does not escape any HTML in a url, for + * that use the escape.entities() method. + * + * @param {string} url + * @return {string} + * @since 1.4.5 + */ + function uriScheme(url) { + var path, + // If there is a : before a / then it has a scheme + hasScheme = /^[^\/]*:/i, + location = window.location; + + // Has no scheme or a valid scheme + if ((!url || !hasScheme.test(url)) || VALID_SCHEME_REGEX.test(url)) { + return url; + } + + path = location.pathname.split('/'); + path.pop(); + + return location.protocol + '//' + + location.host + + path.join('/') + '/' + + url; + } + + /** + * HTML templates used by the editor and default commands + * @type {Object} + * @private + */ + var _templates = { + html: + '<!DOCTYPE html>' + + '<html{attrs}>' + + '<head>' + + '<meta http-equiv="Content-Type" ' + + 'content="text/html;charset={charset}" />' + + '<link rel="stylesheet" type="text/css" href="{style}" />' + + '</head>' + + '<body contenteditable="true" {spellcheck}><p></p></body>' + + '</html>', + + toolbarButton: '<a class="sceditor-button sceditor-button-{name}" ' + + 'data-sceditor-command="{name}" unselectable="on">' + + '<div unselectable="on">{dispName}</div></a>', + + emoticon: '<img src="{url}" data-sceditor-emoticon="{key}" ' + + 'alt="{key}" title="{tooltip}" />', + + fontOpt: '<a class="sceditor-font-option" href="#" ' + + 'data-font="{font}"><font face="{font}">{font}</font></a>', + + sizeOpt: '<a class="sceditor-fontsize-option" data-size="{size}" ' + + 'href="#"><font size="{size}">{size}</font></a>', + + pastetext: + '<div><label for="txt">{label}</label> ' + + '<textarea cols="20" rows="7" id="txt"></textarea></div>' + + '<div><input type="button" class="button" value="{insert}" />' + + '</div>', + + table: + '<div><label for="rows">{rows}</label><input type="text" ' + + 'id="rows" value="2" /></div>' + + '<div><label for="cols">{cols}</label><input type="text" ' + + 'id="cols" value="2" /></div>' + + '<div><input type="button" class="button" value="{insert}"' + + ' /></div>', + + image: + '<div><label for="image">{url}</label> ' + + '<input type="text" id="image" dir="ltr" placeholder="https://" /></div>' + + '<div><label for="width">{width}</label> ' + + '<input type="text" id="width" size="2" dir="ltr" /></div>' + + '<div><label for="height">{height}</label> ' + + '<input type="text" id="height" size="2" dir="ltr" /></div>' + + '<div><input type="button" class="button" value="{insert}" />' + + '</div>', + + email: + '<div><label for="email">{label}</label> ' + + '<input type="text" id="email" dir="ltr" /></div>' + + '<div><label for="des">{desc}</label> ' + + '<input type="text" id="des" /></div>' + + '<div><input type="button" class="button" value="{insert}" />' + + '</div>', + + link: + '<div><label for="link">{url}</label> ' + + '<input type="text" id="link" dir="ltr" placeholder="https://" /></div>' + + '<div><label for="des">{desc}</label> ' + + '<input type="text" id="des" /></div>' + + '<div><input type="button" class="button" value="{ins}" /></div>', + + youtubeMenu: + '<div><label for="link">{label}</label> ' + + '<input type="text" id="link" dir="ltr" placeholder="https://" /></div>' + + '<div><input type="button" class="button" value="{insert}" />' + + '</div>', + + youtube: + '<iframe width="560" height="315" frameborder="0" allowfullscreen ' + + 'src="https://www.youtube-nocookie.com/embed/{id}?wmode=opaque&start={time}" ' + + 'data-youtube-id="{id}"></iframe>' + }; + + /** + * Replaces any params in a template with the passed params. + * + * If createHtml is passed it will return a DocumentFragment + * containing the parsed template. + * + * @param {string} name + * @param {Object} [params] + * @param {boolean} [createHtml] + * @returns {string|DocumentFragment} + * @private + */ + function _tmpl (name, params, createHtml) { + var template = _templates[name]; + + Object.keys(params).forEach(function (name) { + template = template.replace( + new RegExp(regex('{' + name + '}'), 'g'), params[name] + ); + }); + + if (createHtml) { + template = parseHTML(template); + } + + return template; + } + + /** + * Fixes a bug in FF where it sometimes wraps + * new lines in their own list item. + * See issue #359 + */ + function fixFirefoxListBug(editor) { + // Only apply to Firefox as will break other browsers. + if ('mozHidden' in document) { + var node = editor.getBody(); + var next; + + while (node) { + next = node; + + if (next.firstChild) { + next = next.firstChild; + } else { + + while (next && !next.nextSibling) { + next = next.parentNode; + } + + if (next) { + next = next.nextSibling; + } + } + + if (node.nodeType === 3 && /[\n\r\t]+/.test(node.nodeValue)) { + // Only remove if newlines are collapsed + if (!/^pre/.test(css(node.parentNode, 'whiteSpace'))) { + remove(node); + } + } + + node = next; + } + } + } + + + /** + * Map of all the commands for SCEditor + * @type {Object} + * @name commands + * @memberOf jQuery.sceditor + */ + var defaultCmds = { + // START_COMMAND: Bold + bold: { + exec: 'bold', + tooltip: 'Bold', + shortcut: 'Ctrl+B' + }, + // END_COMMAND + // START_COMMAND: Italic + italic: { + exec: 'italic', + tooltip: 'Italic', + shortcut: 'Ctrl+I' + }, + // END_COMMAND + // START_COMMAND: Underline + underline: { + exec: 'underline', + tooltip: 'Underline', + shortcut: 'Ctrl+U' + }, + // END_COMMAND + // START_COMMAND: Strikethrough + strike: { + exec: 'strikethrough', + tooltip: 'Strikethrough' + }, + // END_COMMAND + // START_COMMAND: Subscript + subscript: { + exec: 'subscript', + tooltip: 'Subscript' + }, + // END_COMMAND + // START_COMMAND: Superscript + superscript: { + exec: 'superscript', + tooltip: 'Superscript' + }, + // END_COMMAND + + // START_COMMAND: Left + left: { + state: function (node) { + if (node && node.nodeType === 3) { + node = node.parentNode; + } + + if (node) { + var isLtr = css(node, 'direction') === 'ltr'; + var align = css(node, 'textAlign'); + + // Can be -moz-left + return /left/.test(align) || + align === (isLtr ? 'start' : 'end'); + } + }, + exec: 'justifyleft', + tooltip: 'Align left' + }, + // END_COMMAND + // START_COMMAND: Centre + center: { + exec: 'justifycenter', + tooltip: 'Center' + }, + // END_COMMAND + // START_COMMAND: Right + right: { + state: function (node) { + if (node && node.nodeType === 3) { + node = node.parentNode; + } + + if (node) { + var isLtr = css(node, 'direction') === 'ltr'; + var align = css(node, 'textAlign'); + + // Can be -moz-right + return /right/.test(align) || + align === (isLtr ? 'end' : 'start'); + } + }, + exec: 'justifyright', + tooltip: 'Align right' + }, + // END_COMMAND + // START_COMMAND: Justify + justify: { + exec: 'justifyfull', + tooltip: 'Justify' + }, + // END_COMMAND + + // START_COMMAND: Font + font: { + _dropDown: function (editor, caller, callback) { + var content = createElement('div'); + + on(content, 'click', 'a', function (e) { + callback(data(this, 'font')); + editor.closeDropDown(true); + e.preventDefault(); + }); + + editor.opts.fonts.split(',').forEach(function (font) { + appendChild(content, _tmpl('fontOpt', { + font: font + }, true)); + }); + + editor.createDropDown(caller, 'font-picker', content); + }, + exec: function (caller) { + var editor = this; + + defaultCmds.font._dropDown(editor, caller, function (fontName) { + editor.execCommand('fontname', fontName); + }); + }, + tooltip: 'Font Name' + }, + // END_COMMAND + // START_COMMAND: Size + size: { + _dropDown: function (editor, caller, callback) { + var content = createElement('div'); + + on(content, 'click', 'a', function (e) { + callback(data(this, 'size')); + editor.closeDropDown(true); + e.preventDefault(); + }); + + for (var i = 1; i <= 7; i++) { + appendChild(content, _tmpl('sizeOpt', { + size: i + }, true)); + } + + editor.createDropDown(caller, 'fontsize-picker', content); + }, + exec: function (caller) { + var editor = this; + + defaultCmds.size._dropDown(editor, caller, function (fontSize) { + editor.execCommand('fontsize', fontSize); + }); + }, + tooltip: 'Font Size' + }, + // END_COMMAND + // START_COMMAND: Colour + color: { + _dropDown: function (editor, caller, callback) { + var content = createElement('div'), + html = '', + cmd = defaultCmds.color; + + if (!cmd._htmlCache) { + editor.opts.colors.split('|').forEach(function (column) { + html += '<div class="sceditor-color-column">'; + + column.split(',').forEach(function (color) { + html += + '<a href="#" class="sceditor-color-option"' + + ' style="background-color: ' + color + '"' + + ' data-color="' + color + '"></a>'; + }); + + html += '</div>'; + }); + + cmd._htmlCache = html; + } + + appendChild(content, parseHTML(cmd._htmlCache)); + + on(content, 'click', 'a', function (e) { + callback(data(this, 'color')); + editor.closeDropDown(true); + e.preventDefault(); + }); + + editor.createDropDown(caller, 'color-picker', content); + }, + exec: function (caller) { + var editor = this; + + defaultCmds.color._dropDown(editor, caller, function (color) { + editor.execCommand('forecolor', color); + }); + }, + tooltip: 'Font Color' + }, + // END_COMMAND + // START_COMMAND: Remove Format + removeformat: { + exec: 'removeformat', + tooltip: 'Remove Formatting' + }, + // END_COMMAND + + // START_COMMAND: Cut + cut: { + exec: 'cut', + tooltip: 'Cut', + errorMessage: 'Your browser does not allow the cut command. ' + + 'Please use the keyboard shortcut Ctrl/Cmd-X' + }, + // END_COMMAND + // START_COMMAND: Copy + copy: { + exec: 'copy', + tooltip: 'Copy', + errorMessage: 'Your browser does not allow the copy command. ' + + 'Please use the keyboard shortcut Ctrl/Cmd-C' + }, + // END_COMMAND + // START_COMMAND: Paste + paste: { + exec: 'paste', + tooltip: 'Paste', + errorMessage: 'Your browser does not allow the paste command. ' + + 'Please use the keyboard shortcut Ctrl/Cmd-V' + }, + // END_COMMAND + // START_COMMAND: Paste Text + pastetext: { + exec: function (caller) { + var val, + content = createElement('div'), + editor = this; + + appendChild(content, _tmpl('pastetext', { + label: editor._( + 'Paste your text inside the following box:' + ), + insert: editor._('Insert') + }, true)); + + on(content, 'click', '.button', function (e) { + val = find(content, '#txt')[0].value; + + if (val) { + editor.wysiwygEditorInsertText(val); + } + + editor.closeDropDown(true); + e.preventDefault(); + }); + + editor.createDropDown(caller, 'pastetext', content); + }, + tooltip: 'Paste Text' + }, + // END_COMMAND + // START_COMMAND: Bullet List + bulletlist: { + exec: function () { + fixFirefoxListBug(this); + this.execCommand('insertunorderedlist'); + }, + tooltip: 'Bullet list' + }, + // END_COMMAND + // START_COMMAND: Ordered List + orderedlist: { + exec: function () { + fixFirefoxListBug(this); + this.execCommand('insertorderedlist'); + }, + tooltip: 'Numbered list' + }, + // END_COMMAND + // START_COMMAND: Indent + indent: { + state: function (parent, firstBlock) { + // Only works with lists, for now + var range, startParent, endParent; + + if (is(firstBlock, 'li')) { + return 0; + } + + if (is(firstBlock, 'ul,ol,menu')) { + // if the whole list is selected, then this must be + // invalidated because the browser will place a + // <blockquote> there + range = this.getRangeHelper().selectedRange(); + + startParent = range.startContainer.parentNode; + endParent = range.endContainer.parentNode; + + // TODO: could use nodeType for this? + // Maybe just check the firstBlock contains both the start + //and end containers + + // Select the tag, not the textNode + // (that's why the parentNode) + if (startParent !== + startParent.parentNode.firstElementChild || + // work around a bug in FF + (is(endParent, 'li') && endParent !== + endParent.parentNode.lastElementChild)) { + return 0; + } + } + + return -1; + }, + exec: function () { + var editor = this, + block = editor.getRangeHelper().getFirstBlockParent(); + + editor.focus(); + + // An indent system is quite complicated as there are loads + // of complications and issues around how to indent text + // As default, let's just stay with indenting the lists, + // at least, for now. + if (closest(block, 'ul,ol,menu')) { + editor.execCommand('indent'); + } + }, + tooltip: 'Add indent' + }, + // END_COMMAND + // START_COMMAND: Outdent + outdent: { + state: function (parents, firstBlock) { + return closest(firstBlock, 'ul,ol,menu') ? 0 : -1; + }, + exec: function () { + var block = this.getRangeHelper().getFirstBlockParent(); + if (closest(block, 'ul,ol,menu')) { + this.execCommand('outdent'); + } + }, + tooltip: 'Remove one indent' + }, + // END_COMMAND + + // START_COMMAND: Table + table: { + exec: function (caller) { + var editor = this, + content = createElement('div'); + + appendChild(content, _tmpl('table', { + rows: editor._('Rows:'), + cols: editor._('Cols:'), + insert: editor._('Insert') + }, true)); + + on(content, 'click', '.button', function (e) { + var rows = Number(find(content, '#rows')[0].value), + cols = Number(find(content, '#cols')[0].value), + html = '<table>'; + + if (rows > 0 && cols > 0) { + html += Array(rows + 1).join( + '<tr>' + + Array(cols + 1).join( + '<td><br /></td>' + ) + + '</tr>' + ); + + html += '</table>'; + + editor.wysiwygEditorInsertHtml(html); + editor.closeDropDown(true); + e.preventDefault(); + } + }); + + editor.createDropDown(caller, 'inserttable', content); + }, + tooltip: 'Insert a table' + }, + // END_COMMAND + + // START_COMMAND: Horizontal Rule + horizontalrule: { + exec: 'inserthorizontalrule', + tooltip: 'Insert a horizontal rule' + }, + // END_COMMAND + + // START_COMMAND: Code + code: { + exec: function () { + this.wysiwygEditorInsertHtml( + '<code>', + '<br /></code>' + ); + }, + tooltip: 'Code' + }, + // END_COMMAND + + // START_COMMAND: Image + image: { + _dropDown: function (editor, caller, selected, cb) { + var content = createElement('div'); + + appendChild(content, _tmpl('image', { + url: editor._('URL:'), + width: editor._('Width (optional):'), + height: editor._('Height (optional):'), + insert: editor._('Insert') + }, true)); + + + var urlInput = find(content, '#image')[0]; + + urlInput.value = selected; + + on(content, 'click', '.button', function (e) { + if (urlInput.value) { + cb( + urlInput.value, + find(content, '#width')[0].value, + find(content, '#height')[0].value + ); + } + + editor.closeDropDown(true); + e.preventDefault(); + }); + + editor.createDropDown(caller, 'insertimage', content); + }, + exec: function (caller) { + var editor = this; + + defaultCmds.image._dropDown( + editor, + caller, + '', + function (url, width, height) { + var attrs = ''; + + if (width) { + attrs += ' width="' + parseInt(width, 10) + '"'; + } + + if (height) { + attrs += ' height="' + parseInt(height, 10) + '"'; + } + + attrs += ' src="' + entities(url) + '"'; + + editor.wysiwygEditorInsertHtml( + '<img' + attrs + ' />' + ); + } + ); + }, + tooltip: 'Insert an image' + }, + // END_COMMAND + + // START_COMMAND: E-mail + email: { + _dropDown: function (editor, caller, cb) { + var content = createElement('div'); + + appendChild(content, _tmpl('email', { + label: editor._('E-mail:'), + desc: editor._('Description (optional):'), + insert: editor._('Insert') + }, true)); + + on(content, 'click', '.button', function (e) { + var email = find(content, '#email')[0].value; + + if (email) { + cb(email, find(content, '#des')[0].value); + } + + editor.closeDropDown(true); + e.preventDefault(); + }); + + editor.createDropDown(caller, 'insertemail', content); + }, + exec: function (caller) { + var editor = this; + + defaultCmds.email._dropDown( + editor, + caller, + function (email, text) { + if (!editor.getRangeHelper().selectedHtml() || text) { + editor.wysiwygEditorInsertHtml( + '<a href="' + + 'mailto:' + entities(email) + '">' + + entities((text || email)) + + '</a>' + ); + } else { + editor.execCommand('createlink', 'mailto:' + email); + } + } + ); + }, + tooltip: 'Insert an email' + }, + // END_COMMAND + + // START_COMMAND: Link + link: { + _dropDown: function (editor, caller, cb) { + var content = createElement('div'); + + appendChild(content, _tmpl('link', { + url: editor._('URL:'), + desc: editor._('Description (optional):'), + ins: editor._('Insert') + }, true)); + + var linkInput = find(content, '#link')[0]; + + function insertUrl(e) { + if (linkInput.value) { + cb(linkInput.value, find(content, '#des')[0].value); + } + + editor.closeDropDown(true); + e.preventDefault(); + } + + on(content, 'click', '.button', insertUrl); + on(content, 'keypress', function (e) { + // 13 = enter key + if (e.which === 13 && linkInput.value) { + insertUrl(e); + } + }, EVENT_CAPTURE); + + editor.createDropDown(caller, 'insertlink', content); + }, + exec: function (caller) { + var editor = this; + + defaultCmds.link._dropDown(editor, caller, function (url, text) { + if (text || !editor.getRangeHelper().selectedHtml()) { + editor.wysiwygEditorInsertHtml( + '<a href="' + entities(url) + '">' + + entities(text || url) + + '</a>' + ); + } else { + editor.execCommand('createlink', url); + } + }); + }, + tooltip: 'Insert a link' + }, + // END_COMMAND + + // START_COMMAND: Unlink + unlink: { + state: function () { + return closest(this.currentNode(), 'a') ? 0 : -1; + }, + exec: function () { + var anchor = closest(this.currentNode(), 'a'); + + if (anchor) { + while (anchor.firstChild) { + insertBefore(anchor.firstChild, anchor); + } + + remove(anchor); + } + }, + tooltip: 'Unlink' + }, + // END_COMMAND + + + // START_COMMAND: Quote + quote: { + exec: function (caller, html, author) { + var before = '<blockquote>', + end = '</blockquote>'; + + // if there is HTML passed set end to null so any selected + // text is replaced + if (html) { + author = (author ? '<cite>' + + entities(author) + + '</cite>' : ''); + before = before + author + html + end; + end = null; + // if not add a newline to the end of the inserted quote + } else if (this.getRangeHelper().selectedHtml() === '') { + end = '<br />' + end; + } + + this.wysiwygEditorInsertHtml(before, end); + }, + tooltip: 'Insert a Quote' + }, + // END_COMMAND + + // START_COMMAND: Emoticons + emoticon: { + exec: function (caller) { + var editor = this; + + var createContent = function (includeMore) { + var moreLink, + opts = editor.opts, + emoticonsRoot = opts.emoticonsRoot || '', + emoticonsCompat = opts.emoticonsCompat, + rangeHelper = editor.getRangeHelper(), + startSpace = emoticonsCompat && + rangeHelper.getOuterText(true, 1) !== ' ' ? ' ' : '', + endSpace = emoticonsCompat && + rangeHelper.getOuterText(false, 1) !== ' ' ? ' ' : '', + content = createElement('div'), + line = createElement('div'), + perLine = 0, + emoticons = extend( + {}, + opts.emoticons.dropdown, + includeMore ? opts.emoticons.more : {} + ); + + appendChild(content, line); + + perLine = Math.sqrt(Object.keys(emoticons).length); + + on(content, 'click', 'img', function (e) { + editor.insert(startSpace + attr(this, 'alt') + endSpace, + null, false).closeDropDown(true); + + e.preventDefault(); + }); + + each(emoticons, function (code, emoticon) { + appendChild(line, createElement('img', { + src: emoticonsRoot + (emoticon.url || emoticon), + alt: code, + title: emoticon.tooltip || code + })); + + if (line.children.length >= perLine) { + line = createElement('div'); + appendChild(content, line); + } + }); + + if (!includeMore && opts.emoticons.more) { + moreLink = createElement('a', { + className: 'sceditor-more' + }); + + appendChild(moreLink, + document.createTextNode(editor._('More'))); + + on(moreLink, 'click', function (e) { + editor.createDropDown( + caller, 'more-emoticons', createContent(true) + ); + + e.preventDefault(); + }); + + appendChild(content, moreLink); + } + + return content; + }; + + editor.createDropDown(caller, 'emoticons', createContent(false)); + }, + txtExec: function (caller) { + defaultCmds.emoticon.exec.call(this, caller); + }, + tooltip: 'Insert an emoticon' + }, + // END_COMMAND + + // START_COMMAND: YouTube + youtube: { + _dropDown: function (editor, caller, callback) { + var content = createElement('div'); + + appendChild(content, _tmpl('youtubeMenu', { + label: editor._('Video URL:'), + insert: editor._('Insert') + }, true)); + + on(content, 'click', '.button', function (e) { + var val = find(content, '#link')[0].value; + var idMatch = val.match(/(?:v=|v\/|embed\/|youtu.be\/)?([a-zA-Z0-9_-]{11})/); + var timeMatch = val.match(/[&|?](?:star)?t=((\d+[hms]?){1,3})/); + var time = 0; + + if (timeMatch) { + each(timeMatch[1].split(/[hms]/), function (i, val) { + if (val !== '') { + time = (time * 60) + Number(val); + } + }); + } + + if (idMatch && /^[a-zA-Z0-9_\-]{11}$/.test(idMatch[1])) { + callback(idMatch[1], time); + } + + editor.closeDropDown(true); + e.preventDefault(); + }); + + editor.createDropDown(caller, 'insertlink', content); + }, + exec: function (btn) { + var editor = this; + + defaultCmds.youtube._dropDown(editor, btn, function (id, time) { + editor.wysiwygEditorInsertHtml(_tmpl('youtube', { + id: id, + time: time + })); + }); + }, + tooltip: 'Insert a YouTube video' + }, + // END_COMMAND + + // START_COMMAND: Date + date: { + _date: function (editor) { + var now = new Date(), + year = now.getYear(), + month = now.getMonth() + 1, + day = now.getDate(); + + if (year < 2000) { + year = 1900 + year; + } + + if (month < 10) { + month = '0' + month; + } + + if (day < 10) { + day = '0' + day; + } + + return editor.opts.dateFormat + .replace(/year/i, year) + .replace(/month/i, month) + .replace(/day/i, day); + }, + exec: function () { + this.insertText(defaultCmds.date._date(this)); + }, + txtExec: function () { + this.insertText(defaultCmds.date._date(this)); + }, + tooltip: 'Insert current date' + }, + // END_COMMAND + + // START_COMMAND: Time + time: { + _time: function () { + var now = new Date(), + hours = now.getHours(), + mins = now.getMinutes(), + secs = now.getSeconds(); + + if (hours < 10) { + hours = '0' + hours; + } + + if (mins < 10) { + mins = '0' + mins; + } + + if (secs < 10) { + secs = '0' + secs; + } + + return hours + ':' + mins + ':' + secs; + }, + exec: function () { + this.insertText(defaultCmds.time._time()); + }, + txtExec: function () { + this.insertText(defaultCmds.time._time()); + }, + tooltip: 'Insert current time' + }, + // END_COMMAND + + + // START_COMMAND: Ltr + ltr: { + state: function (parents, firstBlock) { + return firstBlock && firstBlock.style.direction === 'ltr'; + }, + exec: function () { + var editor = this, + rangeHelper = editor.getRangeHelper(), + node = rangeHelper.getFirstBlockParent(); + + editor.focus(); + + if (!node || is(node, 'body')) { + editor.execCommand('formatBlock', 'p'); + + node = rangeHelper.getFirstBlockParent(); + + if (!node || is(node, 'body')) { + return; + } + } + + var toggleValue = css(node, 'direction') === 'ltr' ? '' : 'ltr'; + css(node, 'direction', toggleValue); + }, + tooltip: 'Left-to-Right' + }, + // END_COMMAND + + // START_COMMAND: Rtl + rtl: { + state: function (parents, firstBlock) { + return firstBlock && firstBlock.style.direction === 'rtl'; + }, + exec: function () { + var editor = this, + rangeHelper = editor.getRangeHelper(), + node = rangeHelper.getFirstBlockParent(); + + editor.focus(); + + if (!node || is(node, 'body')) { + editor.execCommand('formatBlock', 'p'); + + node = rangeHelper.getFirstBlockParent(); + + if (!node || is(node, 'body')) { + return; + } + } + + var toggleValue = css(node, 'direction') === 'rtl' ? '' : 'rtl'; + css(node, 'direction', toggleValue); + }, + tooltip: 'Right-to-Left' + }, + // END_COMMAND + + + // START_COMMAND: Print + print: { + exec: 'print', + tooltip: 'Print' + }, + // END_COMMAND + + // START_COMMAND: Maximize + maximize: { + state: function () { + return this.maximize(); + }, + exec: function () { + this.maximize(!this.maximize()); + this.focus(); + }, + txtExec: function () { + this.maximize(!this.maximize()); + this.focus(); + }, + tooltip: 'Maximize', + shortcut: 'Ctrl+Shift+M' + }, + // END_COMMAND + + // START_COMMAND: Source + source: { + state: function () { + return this.sourceMode(); + }, + exec: function () { + this.toggleSourceMode(); + this.focus(); + }, + txtExec: function () { + this.toggleSourceMode(); + this.focus(); + }, + tooltip: 'View source', + shortcut: 'Ctrl+Shift+S' + }, + // END_COMMAND + + // this is here so that commands above can be removed + // without having to remove the , after the last one. + // Needed for IE. + ignore: {} + }; + + var plugins = {}; + + /** + * Plugin Manager class + * @class PluginManager + * @name PluginManager + */ + function PluginManager(thisObj) { + /** + * Alias of this + * + * @private + * @type {Object} + */ + var base = this; + + /** + * Array of all currently registered plugins + * + * @type {Array} + * @private + */ + var registeredPlugins = []; + + + /** + * Changes a signals name from "name" into "signalName". + * + * @param {string} signal + * @return {string} + * @private + */ + var formatSignalName = function (signal) { + return 'signal' + signal.charAt(0).toUpperCase() + signal.slice(1); + }; + + /** + * Calls handlers for a signal + * + * @see call() + * @see callOnlyFirst() + * @param {Array} args + * @param {boolean} returnAtFirst + * @return {*} + * @private + */ + var callHandlers = function (args, returnAtFirst) { + args = [].slice.call(args); + + var idx, ret, + signal = formatSignalName(args.shift()); + + for (idx = 0; idx < registeredPlugins.length; idx++) { + if (signal in registeredPlugins[idx]) { + ret = registeredPlugins[idx][signal].apply(thisObj, args); + + if (returnAtFirst) { + return ret; + } + } + } + }; + + /** + * Calls all handlers for the passed signal + * + * @param {string} signal + * @param {...string} args + * @function + * @name call + * @memberOf PluginManager.prototype + */ + base.call = function () { + callHandlers(arguments, false); + }; + + /** + * Calls the first handler for a signal, and returns the + * + * @param {string} signal + * @param {...string} args + * @return {*} The result of calling the handler + * @function + * @name callOnlyFirst + * @memberOf PluginManager.prototype + */ + base.callOnlyFirst = function () { + return callHandlers(arguments, true); + }; + + /** + * Checks if a signal has a handler + * + * @param {string} signal + * @return {boolean} + * @function + * @name hasHandler + * @memberOf PluginManager.prototype + */ + base.hasHandler = function (signal) { + var i = registeredPlugins.length; + signal = formatSignalName(signal); + + while (i--) { + if (signal in registeredPlugins[i]) { + return true; + } + } + + return false; + }; + + /** + * Checks if the plugin exists in plugins + * + * @param {string} plugin + * @return {boolean} + * @function + * @name exists + * @memberOf PluginManager.prototype + */ + base.exists = function (plugin) { + if (plugin in plugins) { + plugin = plugins[plugin]; + + return typeof plugin === 'function' && + typeof plugin.prototype === 'object'; + } + + return false; + }; + + /** + * Checks if the passed plugin is currently registered. + * + * @param {string} plugin + * @return {boolean} + * @function + * @name isRegistered + * @memberOf PluginManager.prototype + */ + base.isRegistered = function (plugin) { + if (base.exists(plugin)) { + var idx = registeredPlugins.length; + + while (idx--) { + if (registeredPlugins[idx] instanceof plugins[plugin]) { + return true; + } + } + } + + return false; + }; + + /** + * Registers a plugin to receive signals + * + * @param {string} plugin + * @return {boolean} + * @function + * @name register + * @memberOf PluginManager.prototype + */ + base.register = function (plugin) { + if (!base.exists(plugin) || base.isRegistered(plugin)) { + return false; + } + + plugin = new plugins[plugin](); + registeredPlugins.push(plugin); + + if ('init' in plugin) { + plugin.init.call(thisObj); + } + + return true; + }; + + /** + * Deregisters a plugin. + * + * @param {string} plugin + * @return {boolean} + * @function + * @name deregister + * @memberOf PluginManager.prototype + */ + base.deregister = function (plugin) { + var removedPlugin, + pluginIdx = registeredPlugins.length, + removed = false; + + if (!base.isRegistered(plugin)) { + return removed; + } + + while (pluginIdx--) { + if (registeredPlugins[pluginIdx] instanceof plugins[plugin]) { + removedPlugin = registeredPlugins.splice(pluginIdx, 1)[0]; + removed = true; + + if ('destroy' in removedPlugin) { + removedPlugin.destroy.call(thisObj); + } + } + } + + return removed; + }; + + /** + * Clears all plugins and removes the owner reference. + * + * Calling any functions on this object after calling + * destroy will cause a JS error. + * + * @name destroy + * @memberOf PluginManager.prototype + */ + base.destroy = function () { + var i = registeredPlugins.length; + + while (i--) { + if ('destroy' in registeredPlugins[i]) { + registeredPlugins[i].destroy.call(thisObj); + } + } + + registeredPlugins = []; + thisObj = null; + }; + } + PluginManager.plugins = plugins; + + /** + * Gets the text, start/end node and offset for + * length chars left or right of the passed node + * at the specified offset. + * + * @param {Node} node + * @param {number} offset + * @param {boolean} isLeft + * @param {number} length + * @return {Object} + * @private + */ + var outerText = function (range, isLeft, length) { + var nodeValue, remaining, start, end, node, + text = '', + next = range.startContainer, + offset = range.startOffset; + + // Handle cases where node is a paragraph and offset + // refers to the index of a text node. + // 3 = text node + if (next && next.nodeType !== 3) { + next = next.childNodes[offset]; + offset = 0; + } + + start = end = offset; + + while (length > text.length && next && next.nodeType === 3) { + nodeValue = next.nodeValue; + remaining = length - text.length; + + // If not the first node, start and end should be at their + // max values as will be updated when getting the text + if (node) { + end = nodeValue.length; + start = 0; + } + + node = next; + + if (isLeft) { + start = Math.max(end - remaining, 0); + offset = start; + + text = nodeValue.substr(start, end - start) + text; + next = node.previousSibling; + } else { + end = Math.min(remaining, nodeValue.length); + offset = start + end; + + text += nodeValue.substr(start, end); + next = node.nextSibling; + } + } + + return { + node: node || next, + offset: offset, + text: text + }; + }; + + /** + * Range helper + * + * @class RangeHelper + * @name RangeHelper + */ + function RangeHelper(win, d, sanitize) { + var _createMarker, _prepareInput, + doc = d || win.contentDocument || win.document, + startMarker = 'sceditor-start-marker', + endMarker = 'sceditor-end-marker', + base = this; + + /** + * Inserts HTML into the current range replacing any selected + * text. + * + * If endHTML is specified the selected contents will be put between + * html and endHTML. If there is nothing selected html and endHTML are + * just concatenate together. + * + * @param {string} html + * @param {string} [endHTML] + * @return False on fail + * @function + * @name insertHTML + * @memberOf RangeHelper.prototype + */ + base.insertHTML = function (html, endHTML) { + var node, div, + range = base.selectedRange(); + + if (!range) { + return false; + } + + if (endHTML) { + html += base.selectedHtml() + endHTML; + } + + div = createElement('p', {}, doc); + node = doc.createDocumentFragment(); + div.innerHTML = sanitize(html); + + while (div.firstChild) { + appendChild(node, div.firstChild); + } + + base.insertNode(node); + }; + + /** + * Prepares HTML to be inserted by adding a zero width space + * if the last child is empty and adding the range start/end + * markers to the last child. + * + * @param {Node|string} node + * @param {Node|string} [endNode] + * @param {boolean} [returnHtml] + * @return {Node|string} + * @private + */ + _prepareInput = function (node, endNode, returnHtml) { + var lastChild, + frag = doc.createDocumentFragment(); + + if (typeof node === 'string') { + if (endNode) { + node += base.selectedHtml() + endNode; + } + + frag = parseHTML(node); + } else { + appendChild(frag, node); + + if (endNode) { + appendChild(frag, base.selectedRange().extractContents()); + appendChild(frag, endNode); + } + } + + if (!(lastChild = frag.lastChild)) { + return; + } + + while (!isInline(lastChild.lastChild, true)) { + lastChild = lastChild.lastChild; + } + + if (canHaveChildren(lastChild)) { + // Webkit won't allow the cursor to be placed inside an + // empty tag, so add a zero width space to it. + if (!lastChild.lastChild) { + appendChild(lastChild, document.createTextNode('\u200B')); + } + } else { + lastChild = frag; + } + + base.removeMarkers(); + + // Append marks to last child so when restored cursor will be in + // the right place + appendChild(lastChild, _createMarker(startMarker)); + appendChild(lastChild, _createMarker(endMarker)); + + if (returnHtml) { + var div = createElement('div'); + appendChild(div, frag); + + return div.innerHTML; + } + + return frag; + }; + + /** + * The same as insertHTML except with DOM nodes instead + * + * <strong>Warning:</strong> the nodes must belong to the + * document they are being inserted into. Some browsers + * will throw exceptions if they don't. + * + * Returns boolean false on fail + * + * @param {Node} node + * @param {Node} endNode + * @return {false|undefined} + * @function + * @name insertNode + * @memberOf RangeHelper.prototype + */ + base.insertNode = function (node, endNode) { + var first, last, + input = _prepareInput(node, endNode), + range = base.selectedRange(), + parent = range.commonAncestorContainer, + emptyNodes = []; + + if (!input) { + return false; + } + + function removeIfEmpty(node) { + // Only remove empty node if it wasn't already empty + if (node && isEmpty(node) && emptyNodes.indexOf(node) < 0) { + remove(node); + } + } + + if (range.startContainer !== range.endContainer) { + each(parent.childNodes, function (_, node) { + if (isEmpty(node)) { + emptyNodes.push(node); + } + }); + + first = input.firstChild; + last = input.lastChild; + } + + range.deleteContents(); + + // FF allows <br /> to be selected but inserting a node + // into <br /> will cause it not to be displayed so must + // insert before the <br /> in FF. + // 3 = TextNode + if (parent && parent.nodeType !== 3 && !canHaveChildren(parent)) { + insertBefore(input, parent); + } else { + range.insertNode(input); + + // If a node was split or its contents deleted, remove any resulting + // empty tags. For example: + // <p>|test</p><div>test|</div> + // When deleteContents could become: + // <p></p>|<div></div> + // So remove the empty ones + removeIfEmpty(first && first.previousSibling); + removeIfEmpty(last && last.nextSibling); + } + + base.restoreRange(); + }; + + /** + * Clones the selected Range + * + * @return {Range} + * @function + * @name cloneSelected + * @memberOf RangeHelper.prototype + */ + base.cloneSelected = function () { + var range = base.selectedRange(); + + if (range) { + return range.cloneRange(); + } + }; + + /** + * Gets the selected Range + * + * @return {Range} + * @function + * @name selectedRange + * @memberOf RangeHelper.prototype + */ + base.selectedRange = function () { + var range, firstChild, + sel = win.getSelection(); + + if (!sel) { + return; + } + + // When creating a new range, set the start to the first child + // element of the body element to avoid errors in FF. + if (sel.rangeCount <= 0) { + firstChild = doc.body; + while (firstChild.firstChild) { + firstChild = firstChild.firstChild; + } + + range = doc.createRange(); + // Must be setStartBefore otherwise it can cause infinite + // loops with lists in WebKit. See issue 442 + range.setStartBefore(firstChild); + + sel.addRange(range); + } + + if (sel.rangeCount > 0) { + range = sel.getRangeAt(0); + } + + return range; + }; + + /** + * Gets if there is currently a selection + * + * @return {boolean} + * @function + * @name hasSelection + * @since 1.4.4 + * @memberOf RangeHelper.prototype + */ + base.hasSelection = function () { + var sel = win.getSelection(); + + return sel && sel.rangeCount > 0; + }; + + /** + * Gets the currently selected HTML + * + * @return {string} + * @function + * @name selectedHtml + * @memberOf RangeHelper.prototype + */ + base.selectedHtml = function () { + var div, + range = base.selectedRange(); + + if (range) { + div = createElement('p', {}, doc); + appendChild(div, range.cloneContents()); + + return div.innerHTML; + } + + return ''; + }; + + /** + * Gets the parent node of the selected contents in the range + * + * @return {HTMLElement} + * @function + * @name parentNode + * @memberOf RangeHelper.prototype + */ + base.parentNode = function () { + var range = base.selectedRange(); + + if (range) { + return range.commonAncestorContainer; + } + }; + + /** + * Gets the first block level parent of the selected + * contents of the range. + * + * @return {HTMLElement} + * @function + * @name getFirstBlockParent + * @memberOf RangeHelper.prototype + */ + /** + * Gets the first block level parent of the selected + * contents of the range. + * + * @param {Node} [n] The element to get the first block level parent from + * @return {HTMLElement} + * @function + * @name getFirstBlockParent^2 + * @since 1.4.1 + * @memberOf RangeHelper.prototype + */ + base.getFirstBlockParent = function (node) { + var func = function (elm) { + if (!isInline(elm, true)) { + return elm; + } + + elm = elm ? elm.parentNode : null; + + return elm ? func(elm) : elm; + }; + + return func(node || base.parentNode()); + }; + + /** + * Inserts a node at either the start or end of the current selection + * + * @param {Bool} start + * @param {Node} node + * @function + * @name insertNodeAt + * @memberOf RangeHelper.prototype + */ + base.insertNodeAt = function (start, node) { + var currentRange = base.selectedRange(), + range = base.cloneSelected(); + + if (!range) { + return false; + } + + range.collapse(start); + range.insertNode(node); + + // Reselect the current range. + // Fixes issue with Chrome losing the selection. Issue#82 + base.selectRange(currentRange); + }; + + /** + * Creates a marker node + * + * @param {string} id + * @return {HTMLSpanElement} + * @private + */ + _createMarker = function (id) { + base.removeMarker(id); + + var marker = createElement('span', { + id: id, + className: 'sceditor-selection sceditor-ignore', + style: 'display:none;line-height:0' + }, doc); + + marker.innerHTML = ' '; + + return marker; + }; + + /** + * Inserts start/end markers for the current selection + * which can be used by restoreRange to re-select the + * range. + * + * @memberOf RangeHelper.prototype + * @function + * @name insertMarkers + */ + base.insertMarkers = function () { + var currentRange = base.selectedRange(); + var startNode = _createMarker(startMarker); + + base.removeMarkers(); + base.insertNodeAt(true, startNode); + + // Fixes issue with end marker sometimes being placed before + // the start marker when the range is collapsed. + if (currentRange && currentRange.collapsed) { + startNode.parentNode.insertBefore( + _createMarker(endMarker), startNode.nextSibling); + } else { + base.insertNodeAt(false, _createMarker(endMarker)); + } + }; + + /** + * Gets the marker with the specified ID + * + * @param {string} id + * @return {Node} + * @function + * @name getMarker + * @memberOf RangeHelper.prototype + */ + base.getMarker = function (id) { + return doc.getElementById(id); + }; + + /** + * Removes the marker with the specified ID + * + * @param {string} id + * @function + * @name removeMarker + * @memberOf RangeHelper.prototype + */ + base.removeMarker = function (id) { + var marker = base.getMarker(id); + + if (marker) { + remove(marker); + } + }; + + /** + * Removes the start/end markers + * + * @function + * @name removeMarkers + * @memberOf RangeHelper.prototype + */ + base.removeMarkers = function () { + base.removeMarker(startMarker); + base.removeMarker(endMarker); + }; + + /** + * Saves the current range location. Alias of insertMarkers() + * + * @function + * @name saveRage + * @memberOf RangeHelper.prototype + */ + base.saveRange = function () { + base.insertMarkers(); + }; + + /** + * Select the specified range + * + * @param {Range} range + * @function + * @name selectRange + * @memberOf RangeHelper.prototype + */ + base.selectRange = function (range) { + var lastChild; + var sel = win.getSelection(); + var container = range.endContainer; + + // Check if cursor is set after a BR when the BR is the only + // child of the parent. In Firefox this causes a line break + // to occur when something is typed. See issue #321 + if (range.collapsed && container && + !isInline(container, true)) { + + lastChild = container.lastChild; + while (lastChild && is(lastChild, '.sceditor-ignore')) { + lastChild = lastChild.previousSibling; + } + + if (is(lastChild, 'br')) { + var rng = doc.createRange(); + rng.setEndAfter(lastChild); + rng.collapse(false); + + if (base.compare(range, rng)) { + range.setStartBefore(lastChild); + range.collapse(true); + } + } + } + + if (sel) { + base.clear(); + sel.addRange(range); + } + }; + + /** + * Restores the last range saved by saveRange() or insertMarkers() + * + * @function + * @name restoreRange + * @memberOf RangeHelper.prototype + */ + base.restoreRange = function () { + var isCollapsed, + range = base.selectedRange(), + start = base.getMarker(startMarker), + end = base.getMarker(endMarker); + + if (!start || !end || !range) { + return false; + } + + isCollapsed = start.nextSibling === end; + + range = doc.createRange(); + range.setStartBefore(start); + range.setEndAfter(end); + + if (isCollapsed) { + range.collapse(true); + } + + base.selectRange(range); + base.removeMarkers(); + }; + + /** + * Selects the text left and right of the current selection + * + * @param {number} left + * @param {number} right + * @since 1.4.3 + * @function + * @name selectOuterText + * @memberOf RangeHelper.prototype + */ + base.selectOuterText = function (left, right) { + var start, end, + range = base.cloneSelected(); + + if (!range) { + return false; + } + + range.collapse(false); + + start = outerText(range, true, left); + end = outerText(range, false, right); + + range.setStart(start.node, start.offset); + range.setEnd(end.node, end.offset); + + base.selectRange(range); + }; + + /** + * Gets the text left or right of the current selection + * + * @param {boolean} before + * @param {number} length + * @return {string} + * @since 1.4.3 + * @function + * @name selectOuterText + * @memberOf RangeHelper.prototype + */ + base.getOuterText = function (before, length) { + var range = base.cloneSelected(); + + if (!range) { + return ''; + } + + range.collapse(!before); + + return outerText(range, before, length).text; + }; + + /** + * Replaces keywords with values based on the current caret position + * + * @param {Array} keywords + * @param {boolean} includeAfter If to include the text after the + * current caret position or just + * text before + * @param {boolean} keywordsSorted If the keywords array is pre + * sorted shortest to longest + * @param {number} longestKeyword Length of the longest keyword + * @param {boolean} requireWhitespace If the key must be surrounded + * by whitespace + * @param {string} keypressChar If this is being called from + * a keypress event, this should be + * set to the pressed character + * @return {boolean} + * @function + * @name replaceKeyword + * @memberOf RangeHelper.prototype + */ + // eslint-disable-next-line max-params + base.replaceKeyword = function ( + keywords, + includeAfter, + keywordsSorted, + longestKeyword, + requireWhitespace, + keypressChar + ) { + if (!keywordsSorted) { + keywords.sort(function (a, b) { + return a[0].length - b[0].length; + }); + } + + var outerText, match, matchPos, startIndex, + leftLen, charsLeft, keyword, keywordLen, + whitespaceRegex = '(^|[\\s\xA0\u2002\u2003\u2009])', + keywordIdx = keywords.length, + whitespaceLen = requireWhitespace ? 1 : 0, + maxKeyLen = longestKeyword || + keywords[keywordIdx - 1][0].length; + + if (requireWhitespace) { + maxKeyLen++; + } + + keypressChar = keypressChar || ''; + outerText = base.getOuterText(true, maxKeyLen); + leftLen = outerText.length; + outerText += keypressChar; + + if (includeAfter) { + outerText += base.getOuterText(false, maxKeyLen); + } + + while (keywordIdx--) { + keyword = keywords[keywordIdx][0]; + keywordLen = keyword.length; + startIndex = Math.max(0, leftLen - keywordLen - whitespaceLen); + matchPos = -1; + + if (requireWhitespace) { + match = outerText + .substr(startIndex) + .match(new RegExp(whitespaceRegex + + regex(keyword) + whitespaceRegex)); + + if (match) { + // Add the length of the text that was removed by + // substr() and also add 1 for the whitespace + matchPos = match.index + startIndex + match[1].length; + } + } else { + matchPos = outerText.indexOf(keyword, startIndex); + } + + if (matchPos > -1) { + // Make sure the match is between before and + // after, not just entirely in one side or the other + if (matchPos <= leftLen && + matchPos + keywordLen + whitespaceLen >= leftLen) { + charsLeft = leftLen - matchPos; + + // If the keypress char is white space then it should + // not be replaced, only chars that are part of the + // key should be replaced. + base.selectOuterText( + charsLeft, + keywordLen - charsLeft - + (/^\S/.test(keypressChar) ? 1 : 0) + ); + + base.insertHTML(keywords[keywordIdx][1]); + return true; + } + } + } + + return false; + }; + + /** + * Compares two ranges. + * + * If rangeB is undefined it will be set to + * the current selected range + * + * @param {Range} rngA + * @param {Range} [rngB] + * @return {boolean} + * @function + * @name compare + * @memberOf RangeHelper.prototype + */ + base.compare = function (rngA, rngB) { + if (!rngB) { + rngB = base.selectedRange(); + } + + if (!rngA || !rngB) { + return !rngA && !rngB; + } + + return rngA.compareBoundaryPoints(Range.END_TO_END, rngB) === 0 && + rngA.compareBoundaryPoints(Range.START_TO_START, rngB) === 0; + }; + + /** + * Removes any current selection + * + * @since 1.4.6 + * @function + * @name clear + * @memberOf RangeHelper.prototype + */ + base.clear = function () { + var sel = win.getSelection(); + + if (sel) { + if (sel.removeAllRanges) { + sel.removeAllRanges(); + } else if (sel.empty) { + sel.empty(); + } + } + }; + } + + var USER_AGENT = navigator.userAgent; + + /** + * Detects if the browser is iOS + * + * Needed to fix iOS specific bugs + * + * @function + * @name ios + * @memberOf jQuery.sceditor + * @type {boolean} + */ + var ios = /iPhone|iPod|iPad| wosbrowser\//i.test(USER_AGENT); + + /** + * If the browser supports WYSIWYG editing (e.g. older mobile browsers). + * + * @function + * @name isWysiwygSupported + * @return {boolean} + */ + var isWysiwygSupported = (function () { + var match, isUnsupported; + + // IE is the only browser to support documentMode + var ie = !!window.document.documentMode; + var legacyEdge = '-ms-ime-align' in document.documentElement.style; + + var div = document.createElement('div'); + div.contentEditable = true; + + // Check if the contentEditable attribute is supported + if (!('contentEditable' in document.documentElement) || + div.contentEditable !== 'true') { + return false; + } + + // I think blackberry supports contentEditable or will at least + // give a valid value for the contentEditable detection above + // so it isn't included in the below tests. + + // I hate having to do UA sniffing but some mobile browsers say they + // support contentediable when it isn't usable, i.e. you can't enter + // text. + // This is the only way I can think of to detect them which is also how + // every other editor I've seen deals with this issue. + + // Exclude Opera mobile and mini + isUnsupported = /Opera Mobi|Opera Mini/i.test(USER_AGENT); + + if (/Android/i.test(USER_AGENT)) { + isUnsupported = true; + + if (/Safari/.test(USER_AGENT)) { + // Android browser 534+ supports content editable + // This also matches Chrome which supports content editable too + match = /Safari\/(\d+)/.exec(USER_AGENT); + isUnsupported = (!match || !match[1] ? true : match[1] < 534); + } + } + + // The current version of Amazon Silk supports it, older versions didn't + // As it uses webkit like Android, assume it's the same and started + // working at versions >= 534 + if (/ Silk\//i.test(USER_AGENT)) { + match = /AppleWebKit\/(\d+)/.exec(USER_AGENT); + isUnsupported = (!match || !match[1] ? true : match[1] < 534); + } + + // iOS 5+ supports content editable + if (ios) { + // Block any version <= 4_x(_x) + isUnsupported = /OS [0-4](_\d)+ like Mac/i.test(USER_AGENT); + } + + // Firefox does support WYSIWYG on mobiles so override + // any previous value if using FF + if (/Firefox/i.test(USER_AGENT)) { + isUnsupported = false; + } + + if (/OneBrowser/i.test(USER_AGENT)) { + isUnsupported = false; + } + + // UCBrowser works but doesn't give a unique user agent + if (navigator.vendor === 'UCWEB') { + isUnsupported = false; + } + + // IE and legacy edge are not supported any more + if (ie || legacyEdge) { + isUnsupported = true; + } + + return !isUnsupported; + }()); + + /** + * Checks all emoticons are surrounded by whitespace and + * replaces any that aren't with with their emoticon code. + * + * @param {HTMLElement} node + * @param {rangeHelper} rangeHelper + * @return {void} + */ + function checkWhitespace(node, rangeHelper) { + var noneWsRegex = /[^\s\xA0\u2002\u2003\u2009]+/; + var emoticons = node && find(node, 'img[data-sceditor-emoticon]'); + + if (!node || !emoticons.length) { + return; + } + + for (var i = 0; i < emoticons.length; i++) { + var emoticon = emoticons[i]; + var parent = emoticon.parentNode; + var prev = emoticon.previousSibling; + var next = emoticon.nextSibling; + + if ((!prev || !noneWsRegex.test(prev.nodeValue.slice(-1))) && + (!next || !noneWsRegex.test((next.nodeValue || '')[0]))) { + continue; + } + + var range = rangeHelper.cloneSelected(); + var rangeStart = -1; + var rangeStartContainer = range.startContainer; + var previousText = prev.nodeValue || ''; + + previousText += data(emoticon, 'sceditor-emoticon'); + + // If the cursor is after the removed emoticon, add + // the length of the newly added text to it + if (rangeStartContainer === next) { + rangeStart = previousText.length + range.startOffset; + } + + // If the cursor is set before the next node, set it to + // the end of the new text node + if (rangeStartContainer === node && + node.childNodes[range.startOffset] === next) { + rangeStart = previousText.length; + } + + // If the cursor is set before the removed emoticon, + // just keep it at that position + if (rangeStartContainer === prev) { + rangeStart = range.startOffset; + } + + if (!next || next.nodeType !== TEXT_NODE) { + next = parent.insertBefore( + parent.ownerDocument.createTextNode(''), next + ); + } + + next.insertData(0, previousText); + remove(prev); + remove(emoticon); + + // Need to update the range starting position if it's been modified + if (rangeStart > -1) { + range.setStart(next, rangeStart); + range.collapse(true); + rangeHelper.selectRange(range); + } + } + } + /** + * Replaces any emoticons inside the root node with images. + * + * emoticons should be an object where the key is the emoticon + * code and the value is the HTML to replace it with. + * + * @param {HTMLElement} root + * @param {Object<string, string>} emoticons + * @param {boolean} emoticonsCompat + * @return {void} + */ + function replace(root, emoticons, emoticonsCompat) { + var doc = root.ownerDocument; + var space = '(^|\\s|\xA0|\u2002|\u2003|\u2009|$)'; + var emoticonCodes = []; + var emoticonRegex = {}; + + // TODO: Make this tag configurable. + if (parent(root, 'code')) { + return; + } + + each(emoticons, function (key) { + emoticonRegex[key] = new RegExp(space + regex(key) + space); + emoticonCodes.push(key); + }); + + // Sort keys longest to shortest so that longer keys + // take precedence (avoids bugs with shorter keys partially + // matching longer ones) + emoticonCodes.sort(function (a, b) { + return b.length - a.length; + }); + + (function convert(node) { + node = node.firstChild; + + while (node) { + // TODO: Make this tag configurable. + if (node.nodeType === ELEMENT_NODE && !is(node, 'code')) { + convert(node); + } + + if (node.nodeType === TEXT_NODE) { + for (var i = 0; i < emoticonCodes.length; i++) { + var text = node.nodeValue; + var key = emoticonCodes[i]; + var index = emoticonsCompat ? + text.search(emoticonRegex[key]) : + text.indexOf(key); + + if (index > -1) { + // When emoticonsCompat is enabled this will be the + // position after any white space + var startIndex = text.indexOf(key, index); + var fragment = parseHTML(emoticons[key], doc); + var after = text.substr(startIndex + key.length); + + fragment.appendChild(doc.createTextNode(after)); + + node.nodeValue = text.substr(0, startIndex); + node.parentNode + .insertBefore(fragment, node.nextSibling); + } + } + } + + node = node.nextSibling; + } + }(root)); + } + + /*! @license DOMPurify | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/2.2.2/LICENSE */ + + function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } + + var hasOwnProperty = Object.hasOwnProperty, + setPrototypeOf = Object.setPrototypeOf, + isFrozen = Object.isFrozen, + getPrototypeOf = Object.getPrototypeOf, + getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + var freeze = Object.freeze, + seal = Object.seal, + create = Object.create; // eslint-disable-line import/no-mutable-exports + + var _ref = typeof Reflect !== 'undefined' && Reflect, + apply = _ref.apply, + construct = _ref.construct; + + if (!apply) { + apply = function apply(fun, thisValue, args) { + return fun.apply(thisValue, args); + }; + } + + if (!freeze) { + freeze = function freeze(x) { + return x; + }; + } + + if (!seal) { + seal = function seal(x) { + return x; + }; + } + + if (!construct) { + construct = function construct(Func, args) { + return new (Function.prototype.bind.apply(Func, [null].concat(_toConsumableArray(args))))(); + }; + } + + var arrayForEach = unapply(Array.prototype.forEach); + var arrayPop = unapply(Array.prototype.pop); + var arrayPush = unapply(Array.prototype.push); + + var stringToLowerCase = unapply(String.prototype.toLowerCase); + var stringMatch = unapply(String.prototype.match); + var stringReplace = unapply(String.prototype.replace); + var stringIndexOf = unapply(String.prototype.indexOf); + var stringTrim = unapply(String.prototype.trim); + + var regExpTest = unapply(RegExp.prototype.test); + + var typeErrorCreate = unconstruct(TypeError); + + function unapply(func) { + return function (thisArg) { + for (var _len = arguments.length, args = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + args[_key - 1] = arguments[_key]; + } + + return apply(func, thisArg, args); + }; + } + + function unconstruct(func) { + return function () { + for (var _len2 = arguments.length, args = Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { + args[_key2] = arguments[_key2]; + } + + return construct(func, args); + }; + } + + /* Add properties to a lookup table */ + function addToSet(set, array) { + if (setPrototypeOf) { + // Make 'in' and truthy checks like Boolean(set.constructor) + // independent of any properties defined on Object.prototype. + // Prevent prototype setters from intercepting set as a this value. + setPrototypeOf(set, null); + } + + var l = array.length; + while (l--) { + var element = array[l]; + if (typeof element === 'string') { + var lcElement = stringToLowerCase(element); + if (lcElement !== element) { + // Config presets (e.g. tags.js, attrs.js) are immutable. + if (!isFrozen(array)) { + array[l] = lcElement; + } + + element = lcElement; + } + } + + set[element] = true; + } + + return set; + } + + /* Shallow clone an object */ + function clone(object) { + var newObject = create(null); + + var property = void 0; + for (property in object) { + if (apply(hasOwnProperty, object, [property])) { + newObject[property] = object[property]; + } + } + + return newObject; + } + + /* IE10 doesn't support __lookupGetter__ so lets' + * simulate it. It also automatically checks + * if the prop is function or getter and behaves + * accordingly. */ + function lookupGetter(object, prop) { + while (object !== null) { + var desc = getOwnPropertyDescriptor(object, prop); + if (desc) { + if (desc.get) { + return unapply(desc.get); + } + + if (typeof desc.value === 'function') { + return unapply(desc.value); + } + } + + object = getPrototypeOf(object); + } + + return null; + } + + var html = freeze(['a', 'abbr', 'acronym', 'address', 'area', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'content', 'data', 'datalist', 'dd', 'decorator', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meter', 'nav', 'nobr', 'ol', 'optgroup', 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'select', 'shadow', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr']); + + // SVG + var svg = freeze(['svg', 'a', 'altglyph', 'altglyphdef', 'altglyphitem', 'animatecolor', 'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'filter', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line', 'lineargradient', 'marker', 'mask', 'metadata', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialgradient', 'rect', 'stop', 'style', 'switch', 'symbol', 'text', 'textpath', 'title', 'tref', 'tspan', 'view', 'vkern']); + + var svgFilters = freeze(['feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence']); + + // List of SVG elements that are disallowed by default. + // We still need to know them so that we can do namespace + // checks properly in case one wants to add them to + // allow-list. + var svgDisallowed = freeze(['animate', 'color-profile', 'cursor', 'discard', 'fedropshadow', 'feimage', 'font-face', 'font-face-format', 'font-face-name', 'font-face-src', 'font-face-uri', 'foreignobject', 'hatch', 'hatchpath', 'mesh', 'meshgradient', 'meshpatch', 'meshrow', 'missing-glyph', 'script', 'set', 'solidcolor', 'unknown', 'use']); + + var mathMl = freeze(['math', 'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mlabeledtr', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mroot', 'mrow', 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover']); + + // Similarly to SVG, we want to know all MathML elements, + // even those that we disallow by default. + var mathMlDisallowed = freeze(['maction', 'maligngroup', 'malignmark', 'mlongdiv', 'mscarries', 'mscarry', 'msgroup', 'mstack', 'msline', 'msrow', 'semantics', 'annotation', 'annotation-xml', 'mprescripts', 'none']); + + var text = freeze(['#text']); + + var html$1 = freeze(['accept', 'action', 'align', 'alt', 'autocapitalize', 'autocomplete', 'autopictureinpicture', 'autoplay', 'background', 'bgcolor', 'border', 'capture', 'cellpadding', 'cellspacing', 'checked', 'cite', 'class', 'clear', 'color', 'cols', 'colspan', 'controls', 'controlslist', 'coords', 'crossorigin', 'datetime', 'decoding', 'default', 'dir', 'disabled', 'disablepictureinpicture', 'disableremoteplayback', 'download', 'draggable', 'enctype', 'enterkeyhint', 'face', 'for', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'id', 'inputmode', 'integrity', 'ismap', 'kind', 'label', 'lang', 'list', 'loading', 'loop', 'low', 'max', 'maxlength', 'media', 'method', 'min', 'minlength', 'multiple', 'muted', 'name', 'noshade', 'novalidate', 'nowrap', 'open', 'optimum', 'pattern', 'placeholder', 'playsinline', 'poster', 'preload', 'pubdate', 'radiogroup', 'readonly', 'rel', 'required', 'rev', 'reversed', 'role', 'rows', 'rowspan', 'spellcheck', 'scope', 'selected', 'shape', 'size', 'sizes', 'span', 'srclang', 'start', 'src', 'srcset', 'step', 'style', 'summary', 'tabindex', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'xmlns']); + + var svg$1 = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'kernelmatrix', 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits', 'maskunits', 'max', 'mask', 'media', 'method', 'mode', 'min', 'name', 'numoctaves', 'offset', 'operator', 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', 'points', 'preservealpha', 'preserveaspectratio', 'primitiveunits', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount', 'repeatdur', 'restart', 'result', 'rotate', 'scale', 'seed', 'shape-rendering', 'specularconstant', 'specularexponent', 'spreadmethod', 'startoffset', 'stddeviation', 'stitchtiles', 'stop-color', 'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width', 'style', 'surfacescale', 'systemlanguage', 'tabindex', 'targetx', 'targety', 'transform', 'text-anchor', 'text-decoration', 'text-rendering', 'textlength', 'type', 'u1', 'u2', 'unicode', 'values', 'viewbox', 'visibility', 'version', 'vert-adv-y', 'vert-origin-x', 'vert-origin-y', 'width', 'word-spacing', 'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2', 'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan']); + + var mathMl$1 = freeze(['accent', 'accentunder', 'align', 'bevelled', 'close', 'columnsalign', 'columnlines', 'columnspan', 'denomalign', 'depth', 'dir', 'display', 'displaystyle', 'encoding', 'fence', 'frame', 'height', 'href', 'id', 'largeop', 'length', 'linethickness', 'lspace', 'lquote', 'mathbackground', 'mathcolor', 'mathsize', 'mathvariant', 'maxsize', 'minsize', 'movablelimits', 'notation', 'numalign', 'open', 'rowalign', 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'rquote', 'scriptlevel', 'scriptminsize', 'scriptsizemultiplier', 'selection', 'separator', 'separators', 'stretchy', 'subscriptshift', 'supscriptshift', 'symmetric', 'voffset', 'width', 'xmlns']); + + var xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']); + + // eslint-disable-next-line unicorn/better-regex + var MUSTACHE_EXPR = seal(/\{\{[\s\S]*|[\s\S]*\}\}/gm); // Specify template detection regex for SAFE_FOR_TEMPLATES mode + var ERB_EXPR = seal(/<%[\s\S]*|[\s\S]*%>/gm); + var DATA_ATTR = seal(/^data-[\-\w.\u00B7-\uFFFF]/); // eslint-disable-line no-useless-escape + var ARIA_ATTR = seal(/^aria-[\-\w]+$/); // eslint-disable-line no-useless-escape + var IS_ALLOWED_URI = seal(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i // eslint-disable-line no-useless-escape + ); + var IS_SCRIPT_OR_DATA = seal(/^(?:\w+script|data):/i); + var ATTR_WHITESPACE = seal(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g // eslint-disable-line no-control-regex + ); + + var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; + + function _toConsumableArray$1(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } + + var getGlobal = function getGlobal() { + return typeof window === 'undefined' ? null : window; + }; + + /** + * Creates a no-op policy for internal use only. + * Don't export this function outside this module! + * @param {?TrustedTypePolicyFactory} trustedTypes The policy factory. + * @param {Document} document The document object (to determine policy name suffix) + * @return {?TrustedTypePolicy} The policy created (or null, if Trusted Types + * are not supported). + */ + var _createTrustedTypesPolicy = function _createTrustedTypesPolicy(trustedTypes, document) { + if ((typeof trustedTypes === 'undefined' ? 'undefined' : _typeof(trustedTypes)) !== 'object' || typeof trustedTypes.createPolicy !== 'function') { + return null; + } + + // Allow the callers to control the unique policy name + // by adding a data-tt-policy-suffix to the script element with the DOMPurify. + // Policy creation with duplicate names throws in Trusted Types. + var suffix = null; + var ATTR_NAME = 'data-tt-policy-suffix'; + if (document.currentScript && document.currentScript.hasAttribute(ATTR_NAME)) { + suffix = document.currentScript.getAttribute(ATTR_NAME); + } + + var policyName = 'dompurify' + (suffix ? '#' + suffix : ''); + + try { + return trustedTypes.createPolicy(policyName, { + createHTML: function createHTML(html$$1) { + return html$$1; + } + }); + } catch (_) { + // Policy creation failed (most likely another DOMPurify script has + // already run). Skip creating the policy, as this will only cause errors + // if TT are enforced. + console.warn('TrustedTypes policy ' + policyName + ' could not be created.'); + return null; + } + }; + + function createDOMPurify() { + var window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal(); + + var DOMPurify = function DOMPurify(root) { + return createDOMPurify(root); + }; + + /** + * Version label, exposed for easier checks + * if DOMPurify is up to date or not + */ + DOMPurify.version = '2.2.6'; + + /** + * Array of elements that DOMPurify removed during sanitation. + * Empty if nothing was removed. + */ + DOMPurify.removed = []; + + if (!window || !window.document || window.document.nodeType !== 9) { + // Not running in a browser, provide a factory function + // so that you can pass your own Window + DOMPurify.isSupported = false; + + return DOMPurify; + } + + var originalDocument = window.document; + + var document = window.document; + var DocumentFragment = window.DocumentFragment, + HTMLTemplateElement = window.HTMLTemplateElement, + Node = window.Node, + Element = window.Element, + NodeFilter = window.NodeFilter, + _window$NamedNodeMap = window.NamedNodeMap, + NamedNodeMap = _window$NamedNodeMap === undefined ? window.NamedNodeMap || window.MozNamedAttrMap : _window$NamedNodeMap, + Text = window.Text, + Comment = window.Comment, + DOMParser = window.DOMParser, + trustedTypes = window.trustedTypes; + + + var ElementPrototype = Element.prototype; + + var cloneNode = lookupGetter(ElementPrototype, 'cloneNode'); + var getNextSibling = lookupGetter(ElementPrototype, 'nextSibling'); + var getChildNodes = lookupGetter(ElementPrototype, 'childNodes'); + var getParentNode = lookupGetter(ElementPrototype, 'parentNode'); + + // As per issue #47, the web-components registry is inherited by a + // new document created via createHTMLDocument. As per the spec + // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries) + // a new empty registry is used when creating a template contents owner + // document, so we use that as our parent document to ensure nothing + // is inherited. + if (typeof HTMLTemplateElement === 'function') { + var template = document.createElement('template'); + if (template.content && template.content.ownerDocument) { + document = template.content.ownerDocument; + } + } + + var trustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, originalDocument); + var emptyHTML = trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML('') : ''; + + var _document = document, + implementation = _document.implementation, + createNodeIterator = _document.createNodeIterator, + getElementsByTagName = _document.getElementsByTagName, + createDocumentFragment = _document.createDocumentFragment; + var importNode = originalDocument.importNode; + + + var documentMode = {}; + try { + documentMode = clone(document).documentMode ? document.documentMode : {}; + } catch (_) {} + + var hooks = {}; + + /** + * Expose whether this browser supports running the full DOMPurify. + */ + DOMPurify.isSupported = implementation && typeof implementation.createHTMLDocument !== 'undefined' && documentMode !== 9; + + var MUSTACHE_EXPR$$1 = MUSTACHE_EXPR, + ERB_EXPR$$1 = ERB_EXPR, + DATA_ATTR$$1 = DATA_ATTR, + ARIA_ATTR$$1 = ARIA_ATTR, + IS_SCRIPT_OR_DATA$$1 = IS_SCRIPT_OR_DATA, + ATTR_WHITESPACE$$1 = ATTR_WHITESPACE; + var IS_ALLOWED_URI$$1 = IS_ALLOWED_URI; + + /** + * We consider the elements and attributes below to be safe. Ideally + * don't add any new ones but feel free to remove unwanted ones. + */ + + /* allowed element names */ + + var ALLOWED_TAGS = null; + var DEFAULT_ALLOWED_TAGS = addToSet({}, [].concat(_toConsumableArray$1(html), _toConsumableArray$1(svg), _toConsumableArray$1(svgFilters), _toConsumableArray$1(mathMl), _toConsumableArray$1(text))); + + /* Allowed attribute names */ + var ALLOWED_ATTR = null; + var DEFAULT_ALLOWED_ATTR = addToSet({}, [].concat(_toConsumableArray$1(html$1), _toConsumableArray$1(svg$1), _toConsumableArray$1(mathMl$1), _toConsumableArray$1(xml))); + + /* Explicitly forbidden tags (overrides ALLOWED_TAGS/ADD_TAGS) */ + var FORBID_TAGS = null; + + /* Explicitly forbidden attributes (overrides ALLOWED_ATTR/ADD_ATTR) */ + var FORBID_ATTR = null; + + /* Decide if ARIA attributes are okay */ + var ALLOW_ARIA_ATTR = true; + + /* Decide if custom data attributes are okay */ + var ALLOW_DATA_ATTR = true; + + /* Decide if unknown protocols are okay */ + var ALLOW_UNKNOWN_PROTOCOLS = false; + + /* Output should be safe for common template engines. + * This means, DOMPurify removes data attributes, mustaches and ERB + */ + var SAFE_FOR_TEMPLATES = false; + + /* Decide if document with <html>... should be returned */ + var WHOLE_DOCUMENT = false; + + /* Track whether config is already set on this instance of DOMPurify. */ + var SET_CONFIG = false; + + /* Decide if all elements (e.g. style, script) must be children of + * document.body. By default, browsers might move them to document.head */ + var FORCE_BODY = false; + + /* Decide if a DOM `HTMLBodyElement` should be returned, instead of a html + * string (or a TrustedHTML object if Trusted Types are supported). + * If `WHOLE_DOCUMENT` is enabled a `HTMLHtmlElement` will be returned instead + */ + var RETURN_DOM = false; + + /* Decide if a DOM `DocumentFragment` should be returned, instead of a html + * string (or a TrustedHTML object if Trusted Types are supported) */ + var RETURN_DOM_FRAGMENT = false; + + /* If `RETURN_DOM` or `RETURN_DOM_FRAGMENT` is enabled, decide if the returned DOM + * `Node` is imported into the current `Document`. If this flag is not enabled the + * `Node` will belong (its ownerDocument) to a fresh `HTMLDocument`, created by + * DOMPurify. + * + * This defaults to `true` starting DOMPurify 2.2.0. Note that setting it to `false` + * might cause XSS from attacks hidden in closed shadowroots in case the browser + * supports Declarative Shadow: DOM https://web.dev/declarative-shadow-dom/ + */ + var RETURN_DOM_IMPORT = true; + + /* Try to return a Trusted Type object instead of a string, return a string in + * case Trusted Types are not supported */ + var RETURN_TRUSTED_TYPE = false; + + /* Output should be free from DOM clobbering attacks? */ + var SANITIZE_DOM = true; + + /* Keep element content when removing element? */ + var KEEP_CONTENT = true; + + /* If a `Node` is passed to sanitize(), then performs sanitization in-place instead + * of importing it into a new Document and returning a sanitized copy */ + var IN_PLACE = false; + + /* Allow usage of profiles like html, svg and mathMl */ + var USE_PROFILES = {}; + + /* Tags to ignore content of when KEEP_CONTENT is true */ + var FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']); + + /* Tags that are safe for data: URIs */ + var DATA_URI_TAGS = null; + var DEFAULT_DATA_URI_TAGS = addToSet({}, ['audio', 'video', 'img', 'source', 'image', 'track']); + + /* Attributes safe for values like "javascript:" */ + var URI_SAFE_ATTRIBUTES = null; + var DEFAULT_URI_SAFE_ATTRIBUTES = addToSet({}, ['alt', 'class', 'for', 'id', 'label', 'name', 'pattern', 'placeholder', 'summary', 'title', 'value', 'style', 'xmlns']); + + /* Keep a reference to config to pass to hooks */ + var CONFIG = null; + + /* Ideally, do not touch anything below this line */ + /* ______________________________________________ */ + + var formElement = document.createElement('form'); + + /** + * _parseConfig + * + * @param {Object} cfg optional config literal + */ + // eslint-disable-next-line complexity + var _parseConfig = function _parseConfig(cfg) { + if (CONFIG && CONFIG === cfg) { + return; + } + + /* Shield configuration object from tampering */ + if (!cfg || (typeof cfg === 'undefined' ? 'undefined' : _typeof(cfg)) !== 'object') { + cfg = {}; + } + + /* Shield configuration object from prototype pollution */ + cfg = clone(cfg); + + /* Set configuration parameters */ + ALLOWED_TAGS = 'ALLOWED_TAGS' in cfg ? addToSet({}, cfg.ALLOWED_TAGS) : DEFAULT_ALLOWED_TAGS; + ALLOWED_ATTR = 'ALLOWED_ATTR' in cfg ? addToSet({}, cfg.ALLOWED_ATTR) : DEFAULT_ALLOWED_ATTR; + URI_SAFE_ATTRIBUTES = 'ADD_URI_SAFE_ATTR' in cfg ? addToSet(clone(DEFAULT_URI_SAFE_ATTRIBUTES), cfg.ADD_URI_SAFE_ATTR) : DEFAULT_URI_SAFE_ATTRIBUTES; + DATA_URI_TAGS = 'ADD_DATA_URI_TAGS' in cfg ? addToSet(clone(DEFAULT_DATA_URI_TAGS), cfg.ADD_DATA_URI_TAGS) : DEFAULT_DATA_URI_TAGS; + FORBID_TAGS = 'FORBID_TAGS' in cfg ? addToSet({}, cfg.FORBID_TAGS) : {}; + FORBID_ATTR = 'FORBID_ATTR' in cfg ? addToSet({}, cfg.FORBID_ATTR) : {}; + USE_PROFILES = 'USE_PROFILES' in cfg ? cfg.USE_PROFILES : false; + ALLOW_ARIA_ATTR = cfg.ALLOW_ARIA_ATTR !== false; // Default true + ALLOW_DATA_ATTR = cfg.ALLOW_DATA_ATTR !== false; // Default true + ALLOW_UNKNOWN_PROTOCOLS = cfg.ALLOW_UNKNOWN_PROTOCOLS || false; // Default false + SAFE_FOR_TEMPLATES = cfg.SAFE_FOR_TEMPLATES || false; // Default false + WHOLE_DOCUMENT = cfg.WHOLE_DOCUMENT || false; // Default false + RETURN_DOM = cfg.RETURN_DOM || false; // Default false + RETURN_DOM_FRAGMENT = cfg.RETURN_DOM_FRAGMENT || false; // Default false + RETURN_DOM_IMPORT = cfg.RETURN_DOM_IMPORT !== false; // Default true + RETURN_TRUSTED_TYPE = cfg.RETURN_TRUSTED_TYPE || false; // Default false + FORCE_BODY = cfg.FORCE_BODY || false; // Default false + SANITIZE_DOM = cfg.SANITIZE_DOM !== false; // Default true + KEEP_CONTENT = cfg.KEEP_CONTENT !== false; // Default true + IN_PLACE = cfg.IN_PLACE || false; // Default false + IS_ALLOWED_URI$$1 = cfg.ALLOWED_URI_REGEXP || IS_ALLOWED_URI$$1; + if (SAFE_FOR_TEMPLATES) { + ALLOW_DATA_ATTR = false; + } + + if (RETURN_DOM_FRAGMENT) { + RETURN_DOM = true; + } + + /* Parse profile info */ + if (USE_PROFILES) { + ALLOWED_TAGS = addToSet({}, [].concat(_toConsumableArray$1(text))); + ALLOWED_ATTR = []; + if (USE_PROFILES.html === true) { + addToSet(ALLOWED_TAGS, html); + addToSet(ALLOWED_ATTR, html$1); + } + + if (USE_PROFILES.svg === true) { + addToSet(ALLOWED_TAGS, svg); + addToSet(ALLOWED_ATTR, svg$1); + addToSet(ALLOWED_ATTR, xml); + } + + if (USE_PROFILES.svgFilters === true) { + addToSet(ALLOWED_TAGS, svgFilters); + addToSet(ALLOWED_ATTR, svg$1); + addToSet(ALLOWED_ATTR, xml); + } + + if (USE_PROFILES.mathMl === true) { + addToSet(ALLOWED_TAGS, mathMl); + addToSet(ALLOWED_ATTR, mathMl$1); + addToSet(ALLOWED_ATTR, xml); + } + } + + /* Merge configuration parameters */ + if (cfg.ADD_TAGS) { + if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) { + ALLOWED_TAGS = clone(ALLOWED_TAGS); + } + + addToSet(ALLOWED_TAGS, cfg.ADD_TAGS); + } + + if (cfg.ADD_ATTR) { + if (ALLOWED_ATTR === DEFAULT_ALLOWED_ATTR) { + ALLOWED_ATTR = clone(ALLOWED_ATTR); + } + + addToSet(ALLOWED_ATTR, cfg.ADD_ATTR); + } + + if (cfg.ADD_URI_SAFE_ATTR) { + addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR); + } + + /* Add #text in case KEEP_CONTENT is set to true */ + if (KEEP_CONTENT) { + ALLOWED_TAGS['#text'] = true; + } + + /* Add html, head and body to ALLOWED_TAGS in case WHOLE_DOCUMENT is true */ + if (WHOLE_DOCUMENT) { + addToSet(ALLOWED_TAGS, ['html', 'head', 'body']); + } + + /* Add tbody to ALLOWED_TAGS in case tables are permitted, see #286, #365 */ + if (ALLOWED_TAGS.table) { + addToSet(ALLOWED_TAGS, ['tbody']); + delete FORBID_TAGS.tbody; + } + + // Prevent further manipulation of configuration. + // Not available in IE8, Safari 5, etc. + if (freeze) { + freeze(cfg); + } + + CONFIG = cfg; + }; + + var MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, ['mi', 'mo', 'mn', 'ms', 'mtext']); + + var HTML_INTEGRATION_POINTS = addToSet({}, ['foreignobject', 'desc', 'title', 'annotation-xml']); + + /* Keep track of all possible SVG and MathML tags + * so that we can perform the namespace checks + * correctly. */ + var ALL_SVG_TAGS = addToSet({}, svg); + addToSet(ALL_SVG_TAGS, svgFilters); + addToSet(ALL_SVG_TAGS, svgDisallowed); + + var ALL_MATHML_TAGS = addToSet({}, mathMl); + addToSet(ALL_MATHML_TAGS, mathMlDisallowed); + + var MATHML_NAMESPACE = 'http://www.w3.org/1998/Math/MathML'; + var SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; + var HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; + + /** + * + * + * @param {Element} element a DOM element whose namespace is being checked + * @returns {boolean} Return false if the element has a + * namespace that a spec-compliant parser would never + * return. Return true otherwise. + */ + var _checkValidNamespace = function _checkValidNamespace(element) { + var parent = getParentNode(element); + + // In JSDOM, if we're inside shadow DOM, then parentNode + // can be null. We just simulate parent in this case. + if (!parent || !parent.tagName) { + parent = { + namespaceURI: HTML_NAMESPACE, + tagName: 'template' + }; + } + + var tagName = stringToLowerCase(element.tagName); + var parentTagName = stringToLowerCase(parent.tagName); + + if (element.namespaceURI === SVG_NAMESPACE) { + // The only way to switch from HTML namespace to SVG + // is via <svg>. If it happens via any other tag, then + // it should be killed. + if (parent.namespaceURI === HTML_NAMESPACE) { + return tagName === 'svg'; + } + + // The only way to switch from MathML to SVG is via + // svg if parent is either <annotation-xml> or MathML + // text integration points. + if (parent.namespaceURI === MATHML_NAMESPACE) { + return tagName === 'svg' && (parentTagName === 'annotation-xml' || MATHML_TEXT_INTEGRATION_POINTS[parentTagName]); + } + + // We only allow elements that are defined in SVG + // spec. All others are disallowed in SVG namespace. + return Boolean(ALL_SVG_TAGS[tagName]); + } + + if (element.namespaceURI === MATHML_NAMESPACE) { + // The only way to switch from HTML namespace to MathML + // is via <math>. If it happens via any other tag, then + // it should be killed. + if (parent.namespaceURI === HTML_NAMESPACE) { + return tagName === 'math'; + } + + // The only way to switch from SVG to MathML is via + // <math> and HTML integration points + if (parent.namespaceURI === SVG_NAMESPACE) { + return tagName === 'math' && HTML_INTEGRATION_POINTS[parentTagName]; + } + + // We only allow elements that are defined in MathML + // spec. All others are disallowed in MathML namespace. + return Boolean(ALL_MATHML_TAGS[tagName]); + } + + if (element.namespaceURI === HTML_NAMESPACE) { + // The only way to switch from SVG to HTML is via + // HTML integration points, and from MathML to HTML + // is via MathML text integration points + if (parent.namespaceURI === SVG_NAMESPACE && !HTML_INTEGRATION_POINTS[parentTagName]) { + return false; + } + + if (parent.namespaceURI === MATHML_NAMESPACE && !MATHML_TEXT_INTEGRATION_POINTS[parentTagName]) { + return false; + } + + // Certain elements are allowed in both SVG and HTML + // namespace. We need to specify them explicitly + // so that they don't get erronously deleted from + // HTML namespace. + var commonSvgAndHTMLElements = addToSet({}, ['title', 'style', 'font', 'a', 'script']); + + // We disallow tags that are specific for MathML + // or SVG and should never appear in HTML namespace + return !ALL_MATHML_TAGS[tagName] && (commonSvgAndHTMLElements[tagName] || !ALL_SVG_TAGS[tagName]); + } + + // The code should never reach this place (this means + // that the element somehow got namespace that is not + // HTML, SVG or MathML). Return false just in case. + return false; + }; + + /** + * _forceRemove + * + * @param {Node} node a DOM node + */ + var _forceRemove = function _forceRemove(node) { + arrayPush(DOMPurify.removed, { element: node }); + try { + node.parentNode.removeChild(node); + } catch (_) { + try { + node.outerHTML = emptyHTML; + } catch (_) { + node.remove(); + } + } + }; + + /** + * _removeAttribute + * + * @param {String} name an Attribute name + * @param {Node} node a DOM node + */ + var _removeAttribute = function _removeAttribute(name, node) { + try { + arrayPush(DOMPurify.removed, { + attribute: node.getAttributeNode(name), + from: node + }); + } catch (_) { + arrayPush(DOMPurify.removed, { + attribute: null, + from: node + }); + } + + node.removeAttribute(name); + }; + + /** + * _initDocument + * + * @param {String} dirty a string of dirty markup + * @return {Document} a DOM, filled with the dirty markup + */ + var _initDocument = function _initDocument(dirty) { + /* Create a HTML document */ + var doc = void 0; + var leadingWhitespace = void 0; + + if (FORCE_BODY) { + dirty = '<remove></remove>' + dirty; + } else { + /* If FORCE_BODY isn't used, leading whitespace needs to be preserved manually */ + var matches = stringMatch(dirty, /^[\r\n\t ]+/); + leadingWhitespace = matches && matches[0]; + } + + var dirtyPayload = trustedTypesPolicy ? trustedTypesPolicy.createHTML(dirty) : dirty; + /* Use the DOMParser API by default, fallback later if needs be */ + try { + doc = new DOMParser().parseFromString(dirtyPayload, 'text/html'); + } catch (_) {} + + /* Use createHTMLDocument in case DOMParser is not available */ + if (!doc || !doc.documentElement) { + doc = implementation.createHTMLDocument(''); + var _doc = doc, + body = _doc.body; + + body.parentNode.removeChild(body.parentNode.firstElementChild); + body.outerHTML = dirtyPayload; + } + + if (dirty && leadingWhitespace) { + doc.body.insertBefore(document.createTextNode(leadingWhitespace), doc.body.childNodes[0] || null); + } + + /* Work on whole document or just its body */ + return getElementsByTagName.call(doc, WHOLE_DOCUMENT ? 'html' : 'body')[0]; + }; + + /** + * _createIterator + * + * @param {Document} root document/fragment to create iterator for + * @return {Iterator} iterator instance + */ + var _createIterator = function _createIterator(root) { + return createNodeIterator.call(root.ownerDocument || root, root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT, function () { + return NodeFilter.FILTER_ACCEPT; + }, false); + }; + + /** + * _isClobbered + * + * @param {Node} elm element to check for clobbering attacks + * @return {Boolean} true if clobbered, false if safe + */ + var _isClobbered = function _isClobbered(elm) { + if (elm instanceof Text || elm instanceof Comment) { + return false; + } + + if (typeof elm.nodeName !== 'string' || typeof elm.textContent !== 'string' || typeof elm.removeChild !== 'function' || !(elm.attributes instanceof NamedNodeMap) || typeof elm.removeAttribute !== 'function' || typeof elm.setAttribute !== 'function' || typeof elm.namespaceURI !== 'string' || typeof elm.insertBefore !== 'function') { + return true; + } + + return false; + }; + + /** + * _isNode + * + * @param {Node} obj object to check whether it's a DOM node + * @return {Boolean} true is object is a DOM node + */ + var _isNode = function _isNode(object) { + return (typeof Node === 'undefined' ? 'undefined' : _typeof(Node)) === 'object' ? object instanceof Node : object && (typeof object === 'undefined' ? 'undefined' : _typeof(object)) === 'object' && typeof object.nodeType === 'number' && typeof object.nodeName === 'string'; + }; + + /** + * _executeHook + * Execute user configurable hooks + * + * @param {String} entryPoint Name of the hook's entry point + * @param {Node} currentNode node to work on with the hook + * @param {Object} data additional hook parameters + */ + var _executeHook = function _executeHook(entryPoint, currentNode, data) { + if (!hooks[entryPoint]) { + return; + } + + arrayForEach(hooks[entryPoint], function (hook) { + hook.call(DOMPurify, currentNode, data, CONFIG); + }); + }; + + /** + * _sanitizeElements + * + * @protect nodeName + * @protect textContent + * @protect removeChild + * + * @param {Node} currentNode to check for permission to exist + * @return {Boolean} true if node was killed, false if left alive + */ + var _sanitizeElements = function _sanitizeElements(currentNode) { + var content = void 0; + + /* Execute a hook if present */ + _executeHook('beforeSanitizeElements', currentNode, null); + + /* Check if element is clobbered or can clobber */ + if (_isClobbered(currentNode)) { + _forceRemove(currentNode); + return true; + } + + /* Check if tagname contains Unicode */ + if (stringMatch(currentNode.nodeName, /[\u0080-\uFFFF]/)) { + _forceRemove(currentNode); + return true; + } + + /* Now let's check the element's type and name */ + var tagName = stringToLowerCase(currentNode.nodeName); + + /* Execute a hook if present */ + _executeHook('uponSanitizeElement', currentNode, { + tagName: tagName, + allowedTags: ALLOWED_TAGS + }); + + /* Detect mXSS attempts abusing namespace confusion */ + if (!_isNode(currentNode.firstElementChild) && (!_isNode(currentNode.content) || !_isNode(currentNode.content.firstElementChild)) && regExpTest(/<[/\w]/g, currentNode.innerHTML) && regExpTest(/<[/\w]/g, currentNode.textContent)) { + _forceRemove(currentNode); + return true; + } + + /* Remove element if anything forbids its presence */ + if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) { + /* Keep content except for bad-listed elements */ + if (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) { + var parentNode = getParentNode(currentNode); + var childNodes = getChildNodes(currentNode); + var childCount = childNodes.length; + for (var i = childCount - 1; i >= 0; --i) { + parentNode.insertBefore(cloneNode(childNodes[i], true), getNextSibling(currentNode)); + } + } + + _forceRemove(currentNode); + return true; + } + + /* Check whether element has a valid namespace */ + if (currentNode instanceof Element && !_checkValidNamespace(currentNode)) { + _forceRemove(currentNode); + return true; + } + + if ((tagName === 'noscript' || tagName === 'noembed') && regExpTest(/<\/no(script|embed)/i, currentNode.innerHTML)) { + _forceRemove(currentNode); + return true; + } + + /* Sanitize element content to be template-safe */ + if (SAFE_FOR_TEMPLATES && currentNode.nodeType === 3) { + /* Get the element's text content */ + content = currentNode.textContent; + content = stringReplace(content, MUSTACHE_EXPR$$1, ' '); + content = stringReplace(content, ERB_EXPR$$1, ' '); + if (currentNode.textContent !== content) { + arrayPush(DOMPurify.removed, { element: currentNode.cloneNode() }); + currentNode.textContent = content; + } + } + + /* Execute a hook if present */ + _executeHook('afterSanitizeElements', currentNode, null); + + return false; + }; + + /** + * _isValidAttribute + * + * @param {string} lcTag Lowercase tag name of containing element. + * @param {string} lcName Lowercase attribute name. + * @param {string} value Attribute value. + * @return {Boolean} Returns true if `value` is valid, otherwise false. + */ + // eslint-disable-next-line complexity + var _isValidAttribute = function _isValidAttribute(lcTag, lcName, value) { + /* Make sure attribute cannot clobber */ + if (SANITIZE_DOM && (lcName === 'id' || lcName === 'name') && (value in document || value in formElement)) { + return false; + } + + /* Allow valid data-* attributes: At least one character after "-" + (https://html.spec.whatwg.org/multipage/dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes) + XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804) + We don't need to check the value; it's always URI safe. */ + if (ALLOW_DATA_ATTR && regExpTest(DATA_ATTR$$1, lcName)) ; else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR$$1, lcName)) ; else if (!ALLOWED_ATTR[lcName] || FORBID_ATTR[lcName]) { + return false; + + /* Check value is safe. First, is attr inert? If so, is safe */ + } else if (URI_SAFE_ATTRIBUTES[lcName]) ; else if (regExpTest(IS_ALLOWED_URI$$1, stringReplace(value, ATTR_WHITESPACE$$1, ''))) ; else if ((lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') && lcTag !== 'script' && stringIndexOf(value, 'data:') === 0 && DATA_URI_TAGS[lcTag]) ; else if (ALLOW_UNKNOWN_PROTOCOLS && !regExpTest(IS_SCRIPT_OR_DATA$$1, stringReplace(value, ATTR_WHITESPACE$$1, ''))) ; else if (!value) ; else { + return false; + } + + return true; + }; + + /** + * _sanitizeAttributes + * + * @protect attributes + * @protect nodeName + * @protect removeAttribute + * @protect setAttribute + * + * @param {Node} currentNode to sanitize + */ + var _sanitizeAttributes = function _sanitizeAttributes(currentNode) { + var attr = void 0; + var value = void 0; + var lcName = void 0; + var l = void 0; + /* Execute a hook if present */ + _executeHook('beforeSanitizeAttributes', currentNode, null); + + var attributes = currentNode.attributes; + + /* Check if we have attributes; if not we might have a text node */ + + if (!attributes) { + return; + } + + var hookEvent = { + attrName: '', + attrValue: '', + keepAttr: true, + allowedAttributes: ALLOWED_ATTR + }; + l = attributes.length; + + /* Go backwards over all attributes; safely remove bad ones */ + while (l--) { + attr = attributes[l]; + var _attr = attr, + name = _attr.name, + namespaceURI = _attr.namespaceURI; + + value = stringTrim(attr.value); + lcName = stringToLowerCase(name); + + /* Execute a hook if present */ + hookEvent.attrName = lcName; + hookEvent.attrValue = value; + hookEvent.keepAttr = true; + hookEvent.forceKeepAttr = undefined; // Allows developers to see this is a property they can set + _executeHook('uponSanitizeAttribute', currentNode, hookEvent); + value = hookEvent.attrValue; + /* Did the hooks approve of the attribute? */ + if (hookEvent.forceKeepAttr) { + continue; + } + + /* Remove attribute */ + _removeAttribute(name, currentNode); + + /* Did the hooks approve of the attribute? */ + if (!hookEvent.keepAttr) { + continue; + } + + /* Work around a security issue in jQuery 3.0 */ + if (regExpTest(/\/>/i, value)) { + _removeAttribute(name, currentNode); + continue; + } + + /* Sanitize attribute content to be template-safe */ + if (SAFE_FOR_TEMPLATES) { + value = stringReplace(value, MUSTACHE_EXPR$$1, ' '); + value = stringReplace(value, ERB_EXPR$$1, ' '); + } + + /* Is `value` valid for this attribute? */ + var lcTag = currentNode.nodeName.toLowerCase(); + if (!_isValidAttribute(lcTag, lcName, value)) { + continue; + } + + /* Handle invalid data-* attribute set by try-catching it */ + try { + if (namespaceURI) { + currentNode.setAttributeNS(namespaceURI, name, value); + } else { + /* Fallback to setAttribute() for browser-unrecognized namespaces e.g. "x-schema". */ + currentNode.setAttribute(name, value); + } + + arrayPop(DOMPurify.removed); + } catch (_) {} + } + + /* Execute a hook if present */ + _executeHook('afterSanitizeAttributes', currentNode, null); + }; + + /** + * _sanitizeShadowDOM + * + * @param {DocumentFragment} fragment to iterate over recursively + */ + var _sanitizeShadowDOM = function _sanitizeShadowDOM(fragment) { + var shadowNode = void 0; + var shadowIterator = _createIterator(fragment); + + /* Execute a hook if present */ + _executeHook('beforeSanitizeShadowDOM', fragment, null); + + while (shadowNode = shadowIterator.nextNode()) { + /* Execute a hook if present */ + _executeHook('uponSanitizeShadowNode', shadowNode, null); + + /* Sanitize tags and elements */ + if (_sanitizeElements(shadowNode)) { + continue; + } + + /* Deep shadow DOM detected */ + if (shadowNode.content instanceof DocumentFragment) { + _sanitizeShadowDOM(shadowNode.content); + } + + /* Check attributes, sanitize if necessary */ + _sanitizeAttributes(shadowNode); + } + + /* Execute a hook if present */ + _executeHook('afterSanitizeShadowDOM', fragment, null); + }; + + /** + * Sanitize + * Public method providing core sanitation functionality + * + * @param {String|Node} dirty string or DOM node + * @param {Object} configuration object + */ + // eslint-disable-next-line complexity + DOMPurify.sanitize = function (dirty, cfg) { + var body = void 0; + var importedNode = void 0; + var currentNode = void 0; + var oldNode = void 0; + var returnNode = void 0; + /* Make sure we have a string to sanitize. + DO NOT return early, as this will return the wrong type if + the user has requested a DOM object rather than a string */ + if (!dirty) { + dirty = '<!-->'; + } + + /* Stringify, in case dirty is an object */ + if (typeof dirty !== 'string' && !_isNode(dirty)) { + // eslint-disable-next-line no-negated-condition + if (typeof dirty.toString !== 'function') { + throw typeErrorCreate('toString is not a function'); + } else { + dirty = dirty.toString(); + if (typeof dirty !== 'string') { + throw typeErrorCreate('dirty is not a string, aborting'); + } + } + } + + /* Check we can run. Otherwise fall back or ignore */ + if (!DOMPurify.isSupported) { + if (_typeof(window.toStaticHTML) === 'object' || typeof window.toStaticHTML === 'function') { + if (typeof dirty === 'string') { + return window.toStaticHTML(dirty); + } + + if (_isNode(dirty)) { + return window.toStaticHTML(dirty.outerHTML); + } + } + + return dirty; + } + + /* Assign config vars */ + if (!SET_CONFIG) { + _parseConfig(cfg); + } + + /* Clean up removed elements */ + DOMPurify.removed = []; + + /* Check if dirty is correctly typed for IN_PLACE */ + if (typeof dirty === 'string') { + IN_PLACE = false; + } + + if (IN_PLACE) ; else if (dirty instanceof Node) { + /* If dirty is a DOM element, append to an empty document to avoid + elements being stripped by the parser */ + body = _initDocument('<!---->'); + importedNode = body.ownerDocument.importNode(dirty, true); + if (importedNode.nodeType === 1 && importedNode.nodeName === 'BODY') { + /* Node is already a body, use as is */ + body = importedNode; + } else if (importedNode.nodeName === 'HTML') { + body = importedNode; + } else { + // eslint-disable-next-line unicorn/prefer-node-append + body.appendChild(importedNode); + } + } else { + /* Exit directly if we have nothing to do */ + if (!RETURN_DOM && !SAFE_FOR_TEMPLATES && !WHOLE_DOCUMENT && + // eslint-disable-next-line unicorn/prefer-includes + dirty.indexOf('<') === -1) { + return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(dirty) : dirty; + } + + /* Initialize the document to work on */ + body = _initDocument(dirty); + + /* Check we have a DOM node from the data */ + if (!body) { + return RETURN_DOM ? null : emptyHTML; + } + } + + /* Remove first element node (ours) if FORCE_BODY is set */ + if (body && FORCE_BODY) { + _forceRemove(body.firstChild); + } + + /* Get node iterator */ + var nodeIterator = _createIterator(IN_PLACE ? dirty : body); + + /* Now start iterating over the created document */ + while (currentNode = nodeIterator.nextNode()) { + /* Fix IE's strange behavior with manipulated textNodes #89 */ + if (currentNode.nodeType === 3 && currentNode === oldNode) { + continue; + } + + /* Sanitize tags and elements */ + if (_sanitizeElements(currentNode)) { + continue; + } + + /* Shadow DOM detected, sanitize it */ + if (currentNode.content instanceof DocumentFragment) { + _sanitizeShadowDOM(currentNode.content); + } + + /* Check attributes, sanitize if necessary */ + _sanitizeAttributes(currentNode); + + oldNode = currentNode; + } + + oldNode = null; + + /* If we sanitized `dirty` in-place, return it. */ + if (IN_PLACE) { + return dirty; + } + + /* Return sanitized string or DOM */ + if (RETURN_DOM) { + if (RETURN_DOM_FRAGMENT) { + returnNode = createDocumentFragment.call(body.ownerDocument); + + while (body.firstChild) { + // eslint-disable-next-line unicorn/prefer-node-append + returnNode.appendChild(body.firstChild); + } + } else { + returnNode = body; + } + + if (RETURN_DOM_IMPORT) { + /* + AdoptNode() is not used because internal state is not reset + (e.g. the past names map of a HTMLFormElement), this is safe + in theory but we would rather not risk another attack vector. + The state that is cloned by importNode() is explicitly defined + by the specs. + */ + returnNode = importNode.call(originalDocument, returnNode, true); + } + + return returnNode; + } + + var serializedHTML = WHOLE_DOCUMENT ? body.outerHTML : body.innerHTML; + + /* Sanitize final string template-safe */ + if (SAFE_FOR_TEMPLATES) { + serializedHTML = stringReplace(serializedHTML, MUSTACHE_EXPR$$1, ' '); + serializedHTML = stringReplace(serializedHTML, ERB_EXPR$$1, ' '); + } + + return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(serializedHTML) : serializedHTML; + }; + + /** + * Public method to set the configuration once + * setConfig + * + * @param {Object} cfg configuration object + */ + DOMPurify.setConfig = function (cfg) { + _parseConfig(cfg); + SET_CONFIG = true; + }; + + /** + * Public method to remove the configuration + * clearConfig + * + */ + DOMPurify.clearConfig = function () { + CONFIG = null; + SET_CONFIG = false; + }; + + /** + * Public method to check if an attribute value is valid. + * Uses last set config, if any. Otherwise, uses config defaults. + * isValidAttribute + * + * @param {string} tag Tag name of containing element. + * @param {string} attr Attribute name. + * @param {string} value Attribute value. + * @return {Boolean} Returns true if `value` is valid. Otherwise, returns false. + */ + DOMPurify.isValidAttribute = function (tag, attr, value) { + /* Initialize shared config vars if necessary. */ + if (!CONFIG) { + _parseConfig({}); + } + + var lcTag = stringToLowerCase(tag); + var lcName = stringToLowerCase(attr); + return _isValidAttribute(lcTag, lcName, value); + }; + + /** + * AddHook + * Public method to add DOMPurify hooks + * + * @param {String} entryPoint entry point for the hook to add + * @param {Function} hookFunction function to execute + */ + DOMPurify.addHook = function (entryPoint, hookFunction) { + if (typeof hookFunction !== 'function') { + return; + } + + hooks[entryPoint] = hooks[entryPoint] || []; + arrayPush(hooks[entryPoint], hookFunction); + }; + + /** + * RemoveHook + * Public method to remove a DOMPurify hook at a given entryPoint + * (pops it from the stack of hooks if more are present) + * + * @param {String} entryPoint entry point for the hook to remove + */ + DOMPurify.removeHook = function (entryPoint) { + if (hooks[entryPoint]) { + arrayPop(hooks[entryPoint]); + } + }; + + /** + * RemoveHooks + * Public method to remove all DOMPurify hooks at a given entryPoint + * + * @param {String} entryPoint entry point for the hooks to remove + */ + DOMPurify.removeHooks = function (entryPoint) { + if (hooks[entryPoint]) { + hooks[entryPoint] = []; + } + }; + + /** + * RemoveAllHooks + * Public method to remove all DOMPurify hooks + * + */ + DOMPurify.removeAllHooks = function () { + hooks = {}; + }; + + return DOMPurify; + } + + var purify = createDOMPurify(); + + var globalWin = window; + var globalDoc = document; + + var IMAGE_MIME_REGEX = /^image\/(p?jpe?g|gif|png|bmp)$/i; + + /** + * Wrap inlines that are in the root in paragraphs. + * + * @param {HTMLBodyElement} body + * @param {Document} doc + * @private + */ + function wrapInlines(body, doc) { + var wrapper; + + traverse(body, function (node) { + if (isInline(node, true)) { + // Ignore text nodes unless they contain non-whitespace chars as + // whitespace will be collapsed. + // Ignore sceditor-ignore elements unless wrapping siblings + // Should still wrap both if wrapping siblings. + if (wrapper || node.nodeType === TEXT_NODE ? + /\S/.test(node.nodeValue) : !is(node, '.sceditor-ignore')) { + if (!wrapper) { + wrapper = createElement('p', {}, doc); + insertBefore(wrapper, node); + } + + appendChild(wrapper, node); + } + } else { + wrapper = null; + } + }, false, true); + } + /** + * SCEditor - A lightweight WYSIWYG editor + * + * @param {HTMLTextAreaElement} original The textarea to be converted + * @param {Object} userOptions + * @class SCEditor + * @name SCEditor + */ + function SCEditor(original, userOptions) { + /** + * Alias of this + * + * @private + */ + var base = this; + + /** + * Editor format like BBCode or HTML + */ + var format; + + /** + * The div which contains the editor and toolbar + * + * @type {HTMLDivElement} + * @private + */ + var editorContainer; + + /** + * Map of events handlers bound to this instance. + * + * @type {Object} + * @private + */ + var eventHandlers = {}; + + /** + * The editors toolbar + * + * @type {HTMLDivElement} + * @private + */ + var toolbar; + + /** + * The editors iframe which should be in design mode + * + * @type {HTMLIFrameElement} + * @private + */ + var wysiwygEditor; + + /** + * The editors window + * + * @type {Window} + * @private + */ + var wysiwygWindow; + + /** + * The WYSIWYG editors body element + * + * @type {HTMLBodyElement} + * @private + */ + var wysiwygBody; + + /** + * The WYSIWYG editors document + * + * @type {Document} + * @private + */ + var wysiwygDocument; + + /** + * The editors textarea for viewing source + * + * @type {HTMLTextAreaElement} + * @private + */ + var sourceEditor; + + /** + * The current dropdown + * + * @type {HTMLDivElement} + * @private + */ + var dropdown; + + /** + * If the user is currently composing text via IME + * @type {boolean} + */ + var isComposing; + + /** + * Timer for valueChanged key handler + * @type {number} + */ + var valueChangedKeyUpTimer; + + /** + * The editors locale + * + * @private + */ + var locale; + + /** + * Stores a cache of preloaded images + * + * @private + * @type {Array.<HTMLImageElement>} + */ + var preLoadCache = []; + + /** + * The editors rangeHelper instance + * + * @type {RangeHelper} + * @private + */ + var rangeHelper; + + /** + * An array of button state handlers + * + * @type {Array.<Object>} + * @private + */ + var btnStateHandlers = []; + + /** + * Plugin manager instance + * + * @type {PluginManager} + * @private + */ + var pluginManager; + + /** + * The current node containing the selection/caret + * + * @type {Node} + * @private + */ + var currentNode; + + /** + * The first block level parent of the current node + * + * @type {node} + * @private + */ + var currentBlockNode; + + /** + * The current node selection/caret + * + * @type {Object} + * @private + */ + var currentSelection; + + /** + * Used to make sure only 1 selection changed + * check is called every 100ms. + * + * Helps improve performance as it is checked a lot. + * + * @type {boolean} + * @private + */ + var isSelectionCheckPending; + + /** + * If content is required (equivalent to the HTML5 required attribute) + * + * @type {boolean} + * @private + */ + var isRequired; + + /** + * The inline CSS style element. Will be undefined + * until css() is called for the first time. + * + * @type {HTMLStyleElement} + * @private + */ + var inlineCss; + + /** + * Object containing a list of shortcut handlers + * + * @type {Object} + * @private + */ + var shortcutHandlers = {}; + + /** + * The min and max heights that autoExpand should stay within + * + * @type {Object} + * @private + */ + var autoExpandBounds; + + /** + * Timeout for the autoExpand function to throttle calls + * + * @private + */ + var autoExpandThrottle; + + /** + * Cache of the current toolbar buttons + * + * @type {Object} + * @private + */ + var toolbarButtons = {}; + + /** + * Last scroll position before maximizing so + * it can be restored when finished. + * + * @type {number} + * @private + */ + var maximizeScrollPosition; + + /** + * Stores the contents while a paste is taking place. + * + * Needed to support browsers that lack clipboard API support. + * + * @type {?DocumentFragment} + * @private + */ + var pasteContentFragment; + + /** + * All the emoticons from dropdown, more and hidden combined + * and with the emoticons root set + * + * @type {!Object<string, string>} + * @private + */ + var allEmoticons = {}; + + /** + * Current icon set if any + * + * @type {?Object} + * @private + */ + var icons; + + /** + * Private functions + * @private + */ + var init, + replaceEmoticons, + handleCommand, + initEditor, + initLocale, + initToolBar, + initOptions, + initEvents, + initResize, + initEmoticons, + handlePasteEvt, + handleCutCopyEvt, + handlePasteData, + handleKeyDown, + handleBackSpace, + handleKeyPress, + handleFormReset, + handleMouseDown, + handleComposition, + handleEvent, + handleDocumentClick, + updateToolBar, + updateActiveButtons, + sourceEditorSelectedText, + appendNewLine, + checkSelectionChanged, + checkNodeChanged, + autofocus, + emoticonsKeyPress, + emoticonsCheckWhitespace, + currentStyledBlockNode, + triggerValueChanged, + valueChangedBlur, + valueChangedKeyUp, + autoUpdate, + autoExpand; + + /** + * All the commands supported by the editor + * @name commands + * @memberOf SCEditor.prototype + */ + base.commands = extend(true, {}, (userOptions.commands || defaultCmds)); + + /** + * Options for this editor instance + * @name opts + * @memberOf SCEditor.prototype + */ + var options = base.opts = extend( + true, {}, defaultOptions, userOptions + ); + + // Don't deep extend emoticons (fixes #565) + base.opts.emoticons = userOptions.emoticons || defaultOptions.emoticons; + + if (!Array.isArray(options.allowedIframeUrls)) { + options.allowedIframeUrls = []; + } + options.allowedIframeUrls.push('https://www.youtube-nocookie.com/embed/'); + + // Create new instance of DOMPurify for each editor instance so can + // have different allowed iframe URLs + // eslint-disable-next-line new-cap + var domPurify = purify(); + + // Allow iframes for things like YouTube, see: + // https://github.com/cure53/DOMPurify/issues/340#issuecomment-670758980 + domPurify.addHook('uponSanitizeElement', function (node, data) { + var allowedUrls = options.allowedIframeUrls; + + if (data.tagName === 'iframe') { + var src = attr(node, 'src') || ''; + + for (var i = 0; i < allowedUrls.length; i++) { + var url = allowedUrls[i]; + + if (isString(url) && src.substr(0, url.length) === url) { + return; + } + + // Handle regex + if (url.test && url.test(src)) { + return; + } + } + + // No match so remove + remove(node); + } + }); + + // Convert target attribute into data-sce-target attributes so XHTML format + // can allow them + domPurify.addHook('afterSanitizeAttributes', function (node) { + if ('target' in node) { + attr(node, 'data-sce-target', attr(node, 'target')); + } + + removeAttr(node, 'target'); + }); + + /** + * Sanitize HTML to avoid XSS + * + * @param {string} html + * @return {string} html + * @private + */ + function sanitize(html) { + return domPurify.sanitize(html, { + ADD_TAGS: ['iframe'], + ADD_ATTR: ['allowfullscreen', 'frameborder', 'target'] + }); + } + /** + * Creates the editor iframe and textarea + * @private + */ + init = function () { + original._sceditor = base; + + // Load locale + if (options.locale && options.locale !== 'en') { + initLocale(); + } + + editorContainer = createElement('div', { + className: 'sceditor-container' + }); + + insertBefore(editorContainer, original); + css(editorContainer, 'z-index', options.zIndex); + + isRequired = original.required; + original.required = false; + + var FormatCtor = SCEditor.formats[options.format]; + format = FormatCtor ? new FormatCtor() : {}; + /* + * Plugins should be initialized before the formatters since + * they may wish to add or change formatting handlers and + * since the bbcode format caches its handlers, + * such changes must be done first. + */ + pluginManager = new PluginManager(base); + (options.plugins || '').split(',').forEach(function (plugin) { + pluginManager.register(plugin.trim()); + }); + if ('init' in format) { + format.init.call(base); + } + + // create the editor + initEmoticons(); + initToolBar(); + initEditor(); + initOptions(); + initEvents(); + + // force into source mode if is a browser that can't handle + // full editing + if (!isWysiwygSupported) { + base.toggleSourceMode(); + } + + updateActiveButtons(); + + var loaded = function () { + off(globalWin, 'load', loaded); + + if (options.autofocus) { + autofocus(!!options.autofocusEnd); + } + + autoExpand(); + appendNewLine(); + // TODO: use editor doc and window? + pluginManager.call('ready'); + if ('onReady' in format) { + format.onReady.call(base); + } + }; + on(globalWin, 'load', loaded); + if (globalDoc.readyState === 'complete') { + loaded(); + } + }; + + /** + * Init the locale variable with the specified locale if possible + * @private + * @return void + */ + initLocale = function () { + var lang; + + locale = SCEditor.locale[options.locale]; + + if (!locale) { + lang = options.locale.split('-'); + locale = SCEditor.locale[lang[0]]; + } + + // Locale DateTime format overrides any specified in the options + if (locale && locale.dateFormat) { + options.dateFormat = locale.dateFormat; + } + }; + + /** + * Creates the editor iframe and textarea + * @private + */ + initEditor = function () { + sourceEditor = createElement('textarea'); + wysiwygEditor = createElement('iframe', { + frameborder: 0, + allowfullscreen: true + }); + + /* + * This needs to be done right after they are created because, + * for any reason, the user may not want the value to be tinkered + * by any filters. + */ + if (options.startInSourceMode) { + addClass(editorContainer, 'sourceMode'); + hide(wysiwygEditor); + } else { + addClass(editorContainer, 'wysiwygMode'); + hide(sourceEditor); + } + + if (!options.spellcheck) { + attr(editorContainer, 'spellcheck', 'false'); + } + + if (globalWin.location.protocol === 'https:') { + attr(wysiwygEditor, 'src', 'about:blank'); + } + + // Add the editor to the container + appendChild(editorContainer, wysiwygEditor); + appendChild(editorContainer, sourceEditor); + + // TODO: make this optional somehow + base.dimensions( + options.width || width(original), + options.height || height(original) + ); + + // Add ios to HTML so can apply CSS fix to only it + var className = ios ? ' ios' : ''; + + wysiwygDocument = wysiwygEditor.contentDocument; + wysiwygDocument.open(); + wysiwygDocument.write(_tmpl('html', { + attrs: ' class="' + className + '"', + spellcheck: options.spellcheck ? '' : 'spellcheck="false"', + charset: options.charset, + style: options.style + })); + wysiwygDocument.close(); + + wysiwygBody = wysiwygDocument.body; + wysiwygWindow = wysiwygEditor.contentWindow; + + base.readOnly(!!options.readOnly); + + // iframe overflow fix for iOS + if (ios) { + height(wysiwygBody, '100%'); + on(wysiwygBody, 'touchend', base.focus); + } + + var tabIndex = attr(original, 'tabindex'); + attr(sourceEditor, 'tabindex', tabIndex); + attr(wysiwygEditor, 'tabindex', tabIndex); + + rangeHelper = new RangeHelper(wysiwygWindow, null, sanitize); + + // load any textarea value into the editor + hide(original); + base.val(original.value); + + var placeholder = options.placeholder || + attr(original, 'placeholder'); + + if (placeholder) { + sourceEditor.placeholder = placeholder; + attr(wysiwygBody, 'placeholder', placeholder); + } + }; + + /** + * Initialises options + * @private + */ + initOptions = function () { + // auto-update original textbox on blur if option set to true + if (options.autoUpdate) { + on(wysiwygBody, 'blur', autoUpdate); + on(sourceEditor, 'blur', autoUpdate); + } + + if (options.rtl === null) { + options.rtl = css(sourceEditor, 'direction') === 'rtl'; + } + + base.rtl(!!options.rtl); + + if (options.autoExpand) { + // Need to update when images (or anything else) loads + on(wysiwygBody, 'load', autoExpand, EVENT_CAPTURE); + on(wysiwygBody, 'input keyup', autoExpand); + } + + if (options.resizeEnabled) { + initResize(); + } + + attr(editorContainer, 'id', options.id); + base.emoticons(options.emoticonsEnabled); + }; + + /** + * Initialises events + * @private + */ + initEvents = function () { + var form = original.form; + var compositionEvents = 'compositionstart compositionend'; + var eventsToForward = + 'keydown keyup keypress focus blur contextmenu input'; + var checkSelectionEvents = 'onselectionchange' in wysiwygDocument ? + 'selectionchange' : + 'keyup focus blur contextmenu mouseup touchend click'; + + on(globalDoc, 'click', handleDocumentClick); + + if (form) { + on(form, 'reset', handleFormReset); + on(form, 'submit', base.updateOriginal, EVENT_CAPTURE); + } + + on(window, 'pagehide', base.updateOriginal); + on(window, 'pageshow', handleFormReset); + on(wysiwygBody, 'keypress', handleKeyPress); + on(wysiwygBody, 'keydown', handleKeyDown); + on(wysiwygBody, 'keydown', handleBackSpace); + on(wysiwygBody, 'keyup', appendNewLine); + on(wysiwygBody, 'blur', valueChangedBlur); + on(wysiwygBody, 'keyup', valueChangedKeyUp); + on(wysiwygBody, 'paste', handlePasteEvt); + on(wysiwygBody, 'cut copy', handleCutCopyEvt); + on(wysiwygBody, compositionEvents, handleComposition); + on(wysiwygBody, checkSelectionEvents, checkSelectionChanged); + on(wysiwygBody, eventsToForward, handleEvent); + + if (options.emoticonsCompat && globalWin.getSelection) { + on(wysiwygBody, 'keyup', emoticonsCheckWhitespace); + } + + on(wysiwygBody, 'blur', function () { + if (!base.val()) { + addClass(wysiwygBody, 'placeholder'); + } + }); + + on(wysiwygBody, 'focus', function () { + removeClass(wysiwygBody, 'placeholder'); + }); + + on(sourceEditor, 'blur', valueChangedBlur); + on(sourceEditor, 'keyup', valueChangedKeyUp); + on(sourceEditor, 'keydown', handleKeyDown); + on(sourceEditor, compositionEvents, handleComposition); + on(sourceEditor, eventsToForward, handleEvent); + + on(wysiwygDocument, 'mousedown', handleMouseDown); + on(wysiwygDocument, checkSelectionEvents, checkSelectionChanged); + on(wysiwygDocument, 'keyup', appendNewLine); + + on(editorContainer, 'selectionchanged', checkNodeChanged); + on(editorContainer, 'selectionchanged', updateActiveButtons); + // Custom events to forward + on( + editorContainer, + 'selectionchanged valuechanged nodechanged pasteraw paste', + handleEvent + ); + }; + + /** + * Creates the toolbar and appends it to the container + * @private + */ + initToolBar = function () { + var group, + commands = base.commands, + exclude = (options.toolbarExclude || '').split(','), + groups = options.toolbar.split('|'); + + toolbar = createElement('div', { + className: 'sceditor-toolbar', + unselectable: 'on' + }); + + if (options.icons in SCEditor.icons) { + icons = new SCEditor.icons[options.icons](); + } + + each(groups, function (_, menuItems) { + group = createElement('div', { + className: 'sceditor-group' + }); + + each(menuItems.split(','), function (_, commandName) { + var button, shortcut, + command = commands[commandName]; + + // The commandName must be a valid command and not excluded + if (!command || exclude.indexOf(commandName) > -1) { + return; + } + + shortcut = command.shortcut; + button = _tmpl('toolbarButton', { + name: commandName, + dispName: base._(command.name || + command.tooltip || commandName) + }, true).firstChild; + + if (icons && icons.create) { + var icon = icons.create(commandName); + if (icon) { + insertBefore(icons.create(commandName), + button.firstChild); + addClass(button, 'has-icon'); + } + } + + button._sceTxtMode = !!command.txtExec; + button._sceWysiwygMode = !!command.exec; + toggleClass(button, 'disabled', !command.exec); + on(button, 'click', function (e) { + if (!hasClass(button, 'disabled')) { + handleCommand(button, command); + } + + updateActiveButtons(); + e.preventDefault(); + }); + // Prevent editor losing focus when button clicked + on(button, 'mousedown', function (e) { + base.closeDropDown(); + e.preventDefault(); + }); + + if (command.tooltip) { + attr(button, 'title', + base._(command.tooltip) + + (shortcut ? ' (' + shortcut + ')' : '') + ); + } + + if (shortcut) { + base.addShortcut(shortcut, commandName); + } + + if (command.state) { + btnStateHandlers.push({ + name: commandName, + state: command.state + }); + // exec string commands can be passed to queryCommandState + } else if (isString(command.exec)) { + btnStateHandlers.push({ + name: commandName, + state: command.exec + }); + } + + appendChild(group, button); + toolbarButtons[commandName] = button; + }); + + // Exclude empty groups + if (group.firstChild) { + appendChild(toolbar, group); + } + }); + + // Append the toolbar to the toolbarContainer option if given + appendChild(options.toolbarContainer || editorContainer, toolbar); + }; + + /** + * Creates the resizer. + * @private + */ + initResize = function () { + var minHeight, maxHeight, minWidth, maxWidth, + mouseMoveFunc, mouseUpFunc, + grip = createElement('div', { + className: 'sceditor-grip' + }), + // Cover is used to cover the editor iframe so document + // still gets mouse move events + cover = createElement('div', { + className: 'sceditor-resize-cover' + }), + moveEvents = 'touchmove mousemove', + endEvents = 'touchcancel touchend mouseup', + startX = 0, + startY = 0, + newX = 0, + newY = 0, + startWidth = 0, + startHeight = 0, + origWidth = width(editorContainer), + origHeight = height(editorContainer), + isDragging = false, + rtl = base.rtl(); + + minHeight = options.resizeMinHeight || origHeight / 1.5; + maxHeight = options.resizeMaxHeight || origHeight * 2.5; + minWidth = options.resizeMinWidth || origWidth / 1.25; + maxWidth = options.resizeMaxWidth || origWidth * 1.25; + + mouseMoveFunc = function (e) { + // iOS uses window.event + if (e.type === 'touchmove') { + e = globalWin.event; + newX = e.changedTouches[0].pageX; + newY = e.changedTouches[0].pageY; + } else { + newX = e.pageX; + newY = e.pageY; + } + + var newHeight = startHeight + (newY - startY), + newWidth = rtl ? + startWidth - (newX - startX) : + startWidth + (newX - startX); + + if (maxWidth > 0 && newWidth > maxWidth) { + newWidth = maxWidth; + } + if (minWidth > 0 && newWidth < minWidth) { + newWidth = minWidth; + } + if (!options.resizeWidth) { + newWidth = false; + } + + if (maxHeight > 0 && newHeight > maxHeight) { + newHeight = maxHeight; + } + if (minHeight > 0 && newHeight < minHeight) { + newHeight = minHeight; + } + if (!options.resizeHeight) { + newHeight = false; + } + + if (newWidth || newHeight) { + base.dimensions(newWidth, newHeight); + } + + e.preventDefault(); + }; + + mouseUpFunc = function (e) { + if (!isDragging) { + return; + } + + isDragging = false; + + hide(cover); + removeClass(editorContainer, 'resizing'); + off(globalDoc, moveEvents, mouseMoveFunc); + off(globalDoc, endEvents, mouseUpFunc); + + e.preventDefault(); + }; + + if (icons && icons.create) { + var icon = icons.create('grip'); + if (icon) { + appendChild(grip, icon); + addClass(grip, 'has-icon'); + } + } + + appendChild(editorContainer, grip); + appendChild(editorContainer, cover); + hide(cover); + + on(grip, 'touchstart mousedown', function (e) { + // iOS uses window.event + if (e.type === 'touchstart') { + e = globalWin.event; + startX = e.touches[0].pageX; + startY = e.touches[0].pageY; + } else { + startX = e.pageX; + startY = e.pageY; + } + + startWidth = width(editorContainer); + startHeight = height(editorContainer); + isDragging = true; + + addClass(editorContainer, 'resizing'); + show(cover); + on(globalDoc, moveEvents, mouseMoveFunc); + on(globalDoc, endEvents, mouseUpFunc); + + e.preventDefault(); + }); + }; + + /** + * Prefixes and preloads the emoticon images + * @private + */ + initEmoticons = function () { + var emoticons = options.emoticons; + var root = options.emoticonsRoot || ''; + + if (emoticons) { + allEmoticons = extend( + {}, emoticons.more, emoticons.dropdown, emoticons.hidden + ); + } + + each(allEmoticons, function (key, url) { + allEmoticons[key] = _tmpl('emoticon', { + key: key, + // Prefix emoticon root to emoticon urls + url: root + (url.url || url), + tooltip: url.tooltip || key + }); + + // Preload the emoticon + if (options.emoticonsEnabled) { + preLoadCache.push(createElement('img', { + src: root + (url.url || url) + })); + } + }); + }; + + /** + * Autofocus the editor + * @private + */ + autofocus = function (focusEnd) { + var range, txtPos, + node = wysiwygBody.firstChild; + + // Can't focus invisible elements + if (!isVisible(editorContainer)) { + return; + } + + if (base.sourceMode()) { + txtPos = focusEnd ? sourceEditor.value.length : 0; + + sourceEditor.setSelectionRange(txtPos, txtPos); + + return; + } + + removeWhiteSpace(wysiwygBody); + + if (focusEnd) { + if (!(node = wysiwygBody.lastChild)) { + node = createElement('p', {}, wysiwygDocument); + appendChild(wysiwygBody, node); + } + + while (node.lastChild) { + node = node.lastChild; + + // Should place the cursor before the last <br> + if (is(node, 'br') && node.previousSibling) { + node = node.previousSibling; + } + } + } + + range = wysiwygDocument.createRange(); + + if (!canHaveChildren(node)) { + range.setStartBefore(node); + + if (focusEnd) { + range.setStartAfter(node); + } + } else { + range.selectNodeContents(node); + } + + range.collapse(!focusEnd); + rangeHelper.selectRange(range); + currentSelection = range; + + if (focusEnd) { + wysiwygBody.scrollTop = wysiwygBody.scrollHeight; + } + + base.focus(); + }; + + /** + * Gets if the editor is read only + * + * @since 1.3.5 + * @function + * @memberOf SCEditor.prototype + * @name readOnly + * @return {boolean} + */ + /** + * Sets if the editor is read only + * + * @param {boolean} readOnly + * @since 1.3.5 + * @function + * @memberOf SCEditor.prototype + * @name readOnly^2 + * @return {this} + */ + base.readOnly = function (readOnly) { + if (typeof readOnly !== 'boolean') { + return !sourceEditor.readonly; + } + + wysiwygBody.contentEditable = !readOnly; + sourceEditor.readonly = !readOnly; + + updateToolBar(readOnly); + + return base; + }; + + /** + * Gets if the editor is in RTL mode + * + * @since 1.4.1 + * @function + * @memberOf SCEditor.prototype + * @name rtl + * @return {boolean} + */ + /** + * Sets if the editor is in RTL mode + * + * @param {boolean} rtl + * @since 1.4.1 + * @function + * @memberOf SCEditor.prototype + * @name rtl^2 + * @return {this} + */ + base.rtl = function (rtl) { + var dir = rtl ? 'rtl' : 'ltr'; + + if (typeof rtl !== 'boolean') { + return attr(sourceEditor, 'dir') === 'rtl'; + } + + attr(wysiwygBody, 'dir', dir); + attr(sourceEditor, 'dir', dir); + + removeClass(editorContainer, 'rtl'); + removeClass(editorContainer, 'ltr'); + addClass(editorContainer, dir); + + if (icons && icons.rtl) { + icons.rtl(rtl); + } + + return base; + }; + + /** + * Updates the toolbar to disable/enable the appropriate buttons + * @private + */ + updateToolBar = function (disable) { + var mode = base.inSourceMode() ? '_sceTxtMode' : '_sceWysiwygMode'; + + each(toolbarButtons, function (_, button) { + toggleClass(button, 'disabled', disable || !button[mode]); + }); + }; + + /** + * Gets the width of the editor in pixels + * + * @since 1.3.5 + * @function + * @memberOf SCEditor.prototype + * @name width + * @return {number} + */ + /** + * Sets the width of the editor + * + * @param {number} width Width in pixels + * @since 1.3.5 + * @function + * @memberOf SCEditor.prototype + * @name width^2 + * @return {this} + */ + /** + * Sets the width of the editor + * + * The saveWidth specifies if to save the width. The stored width can be + * used for things like restoring from maximized state. + * + * @param {number} width Width in pixels + * @param {boolean} [saveWidth=true] If to store the width + * @since 1.4.1 + * @function + * @memberOf SCEditor.prototype + * @name width^3 + * @return {this} + */ + base.width = function (width$1, saveWidth) { + if (!width$1 && width$1 !== 0) { + return width(editorContainer); + } + + base.dimensions(width$1, null, saveWidth); + + return base; + }; + + /** + * Returns an object with the properties width and height + * which are the width and height of the editor in px. + * + * @since 1.4.1 + * @function + * @memberOf SCEditor.prototype + * @name dimensions + * @return {object} + */ + /** + * <p>Sets the width and/or height of the editor.</p> + * + * <p>If width or height is not numeric it is ignored.</p> + * + * @param {number} width Width in px + * @param {number} height Height in px + * @since 1.4.1 + * @function + * @memberOf SCEditor.prototype + * @name dimensions^2 + * @return {this} + */ + /** + * <p>Sets the width and/or height of the editor.</p> + * + * <p>If width or height is not numeric it is ignored.</p> + * + * <p>The save argument specifies if to save the new sizes. + * The saved sizes can be used for things like restoring from + * maximized state. This should normally be left as true.</p> + * + * @param {number} width Width in px + * @param {number} height Height in px + * @param {boolean} [save=true] If to store the new sizes + * @since 1.4.1 + * @function + * @memberOf SCEditor.prototype + * @name dimensions^3 + * @return {this} + */ + base.dimensions = function (width$1, height$1, save) { + // set undefined width/height to boolean false + width$1 = (!width$1 && width$1 !== 0) ? false : width$1; + height$1 = (!height$1 && height$1 !== 0) ? false : height$1; + + if (width$1 === false && height$1 === false) { + return { width: base.width(), height: base.height() }; + } + + if (width$1 !== false) { + if (save !== false) { + options.width = width$1; + } + + width(editorContainer, width$1); + } + + if (height$1 !== false) { + if (save !== false) { + options.height = height$1; + } + + height(editorContainer, height$1); + } + + return base; + }; + + /** + * Gets the height of the editor in px + * + * @since 1.3.5 + * @function + * @memberOf SCEditor.prototype + * @name height + * @return {number} + */ + /** + * Sets the height of the editor + * + * @param {number} height Height in px + * @since 1.3.5 + * @function + * @memberOf SCEditor.prototype + * @name height^2 + * @return {this} + */ + /** + * Sets the height of the editor + * + * The saveHeight specifies if to save the height. + * + * The stored height can be used for things like + * restoring from maximized state. + * + * @param {number} height Height in px + * @param {boolean} [saveHeight=true] If to store the height + * @since 1.4.1 + * @function + * @memberOf SCEditor.prototype + * @name height^3 + * @return {this} + */ + base.height = function (height$1, saveHeight) { + if (!height$1 && height$1 !== 0) { + return height(editorContainer); + } + + base.dimensions(null, height$1, saveHeight); + + return base; + }; + + /** + * Gets if the editor is maximised or not + * + * @since 1.4.1 + * @function + * @memberOf SCEditor.prototype + * @name maximize + * @return {boolean} + */ + /** + * Sets if the editor is maximised or not + * + * @param {boolean} maximize If to maximise the editor + * @since 1.4.1 + * @function + * @memberOf SCEditor.prototype + * @name maximize^2 + * @return {this} + */ + base.maximize = function (maximize) { + var maximizeSize = 'sceditor-maximize'; + + if (isUndefined(maximize)) { + return hasClass(editorContainer, maximizeSize); + } + + maximize = !!maximize; + + if (maximize) { + maximizeScrollPosition = globalWin.pageYOffset; + } + + toggleClass(globalDoc.documentElement, maximizeSize, maximize); + toggleClass(globalDoc.body, maximizeSize, maximize); + toggleClass(editorContainer, maximizeSize, maximize); + base.width(maximize ? '100%' : options.width, false); + base.height(maximize ? '100%' : options.height, false); + + if (!maximize) { + globalWin.scrollTo(0, maximizeScrollPosition); + } + + autoExpand(); + + return base; + }; + + autoExpand = function () { + if (options.autoExpand && !autoExpandThrottle) { + autoExpandThrottle = setTimeout(base.expandToContent, 200); + } + }; + + /** + * Expands or shrinks the editors height to the height of it's content + * + * Unless ignoreMaxHeight is set to true it will not expand + * higher than the maxHeight option. + * + * @since 1.3.5 + * @param {boolean} [ignoreMaxHeight=false] + * @function + * @name expandToContent + * @memberOf SCEditor.prototype + * @see #resizeToContent + */ + base.expandToContent = function (ignoreMaxHeight) { + if (base.maximize()) { + return; + } + + clearTimeout(autoExpandThrottle); + autoExpandThrottle = false; + + if (!autoExpandBounds) { + var height$1 = options.resizeMinHeight || options.height || + height(original); + + autoExpandBounds = { + min: height$1, + max: options.resizeMaxHeight || (height$1 * 2) + }; + } + + var range = globalDoc.createRange(); + range.selectNodeContents(wysiwygBody); + + var rect = range.getBoundingClientRect(); + var current = wysiwygDocument.documentElement.clientHeight - 1; + var spaceNeeded = rect.bottom - rect.top; + var newHeight = base.height() + 1 + (spaceNeeded - current); + + if (!ignoreMaxHeight && autoExpandBounds.max !== -1) { + newHeight = Math.min(newHeight, autoExpandBounds.max); + } + + base.height(Math.ceil(Math.max(newHeight, autoExpandBounds.min))); + }; + + /** + * Destroys the editor, removing all elements and + * event handlers. + * + * Leaves only the original textarea. + * + * @function + * @name destroy + * @memberOf SCEditor.prototype + */ + base.destroy = function () { + // Don't destroy if the editor has already been destroyed + if (!pluginManager) { + return; + } + + pluginManager.destroy(); + + rangeHelper = null; + pluginManager = null; + + if (dropdown) { + remove(dropdown); + } + + off(globalDoc, 'click', handleDocumentClick); + + var form = original.form; + if (form) { + off(form, 'reset', handleFormReset); + off(form, 'submit', base.updateOriginal, EVENT_CAPTURE); + } + + off(window, 'pagehide', base.updateOriginal); + off(window, 'pageshow', handleFormReset); + remove(sourceEditor); + remove(toolbar); + remove(editorContainer); + + delete original._sceditor; + show(original); + + original.required = isRequired; + }; + + + /** + * Creates a menu item drop down + * + * @param {HTMLElement} menuItem The button to align the dropdown with + * @param {string} name Used for styling the dropdown, will be + * a class sceditor-name + * @param {HTMLElement} content The HTML content of the dropdown + * @function + * @name createDropDown + * @memberOf SCEditor.prototype + */ + base.createDropDown = function (menuItem, name, content) { + // first click for create second click for close + var dropDownCss, + dropDownClass = 'sceditor-' + name; + + base.closeDropDown(); + + // Only close the dropdown if it was already open + if (dropdown && hasClass(dropdown, dropDownClass)) { + return; + } + + dropDownCss = extend({ + top: menuItem.offsetTop, + left: menuItem.offsetLeft, + marginTop: menuItem.clientHeight + }, options.dropDownCss); + + dropdown = createElement('div', { + className: 'sceditor-dropdown ' + dropDownClass + }); + + css(dropdown, dropDownCss); + appendChild(dropdown, content); + appendChild(editorContainer, dropdown); + on(dropdown, 'click focusin', function (e) { + // stop clicks within the dropdown from being handled + e.stopPropagation(); + }); + + if (dropdown) { + var first = find(dropdown, 'input,textarea')[0]; + if (first) { + first.focus(); + } + } + }; + + /** + * Handles any document click and closes the dropdown if open + * @private + */ + handleDocumentClick = function (e) { + // ignore right clicks + if (e.which !== 3 && dropdown && !e.defaultPrevented) { + autoUpdate(); + + base.closeDropDown(); + } + }; + + /** + * Handles the WYSIWYG editors cut & copy events + * + * By default browsers also copy inherited styling from the stylesheet and + * browser default styling which is unnecessary. + * + * This will ignore inherited styles and only copy inline styling. + * @private + */ + handleCutCopyEvt = function (e) { + var range = rangeHelper.selectedRange(); + if (range) { + var container = createElement('div', {}, wysiwygDocument); + var firstParent; + + // Copy all inline parent nodes up to the first block parent so can + // copy inline styles + var parent = range.commonAncestorContainer; + while (parent && isInline(parent, true)) { + if (parent.nodeType === ELEMENT_NODE) { + var clone = parent.cloneNode(); + if (container.firstChild) { + appendChild(clone, container.firstChild); + } + + appendChild(container, clone); + firstParent = firstParent || clone; + } + parent = parent.parentNode; + } + + appendChild(firstParent || container, range.cloneContents()); + removeWhiteSpace(container); + + e.clipboardData.setData('text/html', container.innerHTML); + + // TODO: Refactor into private shared module with plaintext plugin + // innerText adds two newlines after <p> tags so convert them to + // <div> tags + each(find(container, 'p'), function (_, elm) { + convertElement(elm, 'div'); + }); + // Remove collapsed <br> tags as innerText converts them to newlines + each(find(container, 'br'), function (_, elm) { + if (!elm.nextSibling || !isInline(elm.nextSibling, true)) { + remove(elm); + } + }); + + // range.toString() doesn't include newlines so can't use that. + // selection.toString() seems to use the same method as innerText + // but needs to be normalised first so using container.innerText + appendChild(wysiwygBody, container); + e.clipboardData.setData('text/plain', container.innerText); + remove(container); + + if (e.type === 'cut') { + range.deleteContents(); + } + + e.preventDefault(); + } + }; + + /** + * Handles the WYSIWYG editors paste event + * @private + */ + handlePasteEvt = function (e) { + var editable = wysiwygBody; + var clipboard = e.clipboardData; + var loadImage = function (file) { + var reader = new FileReader(); + reader.onload = function (e) { + handlePasteData({ + html: '<img src="' + e.target.result + '" />' + }); + }; + reader.readAsDataURL(file); + }; + + // Modern browsers with clipboard API - everything other than _very_ + // old android web views and UC browser which doesn't support the + // paste event at all. + if (clipboard) { + var data = {}; + var types = clipboard.types; + var items = clipboard.items; + + e.preventDefault(); + + for (var i = 0; i < types.length; i++) { + // Word sometimes adds copied text as an image so if HTML + // exists prefer that over images + if (types.indexOf('text/html') < 0) { + // Normalise image pasting to paste as a data-uri + if (globalWin.FileReader && items && + IMAGE_MIME_REGEX.test(items[i].type)) { + return loadImage(clipboard.items[i].getAsFile()); + } + } + + data[types[i]] = clipboard.getData(types[i]); + } + // Call plugins here with file? + data.text = data['text/plain']; + data.html = sanitize(data['text/html']); + + handlePasteData(data); + // If contentsFragment exists then we are already waiting for a + // previous paste so let the handler for that handle this one too + } else if (!pasteContentFragment) { + // Save the scroll position so can be restored + // when contents is restored + var scrollTop = editable.scrollTop; + + rangeHelper.saveRange(); + + pasteContentFragment = globalDoc.createDocumentFragment(); + while (editable.firstChild) { + appendChild(pasteContentFragment, editable.firstChild); + } + + setTimeout(function () { + var html = editable.innerHTML; + + editable.innerHTML = ''; + appendChild(editable, pasteContentFragment); + editable.scrollTop = scrollTop; + pasteContentFragment = false; + + rangeHelper.restoreRange(); + + handlePasteData({ html: sanitize(html) }); + }, 0); + } + }; + + /** + * Gets the pasted data, filters it and then inserts it. + * @param {Object} data + * @private + */ + handlePasteData = function (data) { + var pasteArea = createElement('div', {}, wysiwygDocument); + + pluginManager.call('pasteRaw', data); + trigger(editorContainer, 'pasteraw', data); + + if (data.html) { + // Sanitize again in case plugins modified the HTML + pasteArea.innerHTML = sanitize(data.html); + + // fix any invalid nesting + fixNesting(pasteArea); + } else { + pasteArea.innerHTML = entities(data.text || ''); + } + + var paste = { + val: pasteArea.innerHTML + }; + + if ('fragmentToSource' in format) { + paste.val = format + .fragmentToSource(paste.val, wysiwygDocument, currentNode); + } + + pluginManager.call('paste', paste); + trigger(editorContainer, 'paste', paste); + + if ('fragmentToHtml' in format) { + paste.val = format + .fragmentToHtml(paste.val, currentNode); + } + + pluginManager.call('pasteHtml', paste); + + var parent = rangeHelper.getFirstBlockParent(); + base.wysiwygEditorInsertHtml(paste.val, null, true); + merge(parent); + }; + + /** + * Closes any currently open drop down + * + * @param {boolean} [focus=false] If to focus the editor + * after closing the drop down + * @function + * @name closeDropDown + * @memberOf SCEditor.prototype + */ + base.closeDropDown = function (focus) { + if (dropdown) { + remove(dropdown); + dropdown = null; + } + + if (focus === true) { + base.focus(); + } + }; + + + /** + * Inserts HTML into WYSIWYG editor. + * + * If endHtml is specified, any selected text will be placed + * between html and endHtml. If there is no selected text html + * and endHtml will just be concatenate together. + * + * @param {string} html + * @param {string} [endHtml=null] + * @param {boolean} [overrideCodeBlocking=false] If to insert the html + * into code tags, by + * default code tags only + * support text. + * @function + * @name wysiwygEditorInsertHtml + * @memberOf SCEditor.prototype + */ + base.wysiwygEditorInsertHtml = function ( + html, endHtml, overrideCodeBlocking + ) { + var marker, scrollTop, scrollTo, + editorHeight = height(wysiwygEditor); + + base.focus(); + + // TODO: This code tag should be configurable and + // should maybe convert the HTML into text instead + // Don't apply to code elements + if (!overrideCodeBlocking && closest(currentBlockNode, 'code')) { + return; + } + + // Insert the HTML and save the range so the editor can be scrolled + // to the end of the selection. Also allows emoticons to be replaced + // without affecting the cursor position + rangeHelper.insertHTML(html, endHtml); + rangeHelper.saveRange(); + replaceEmoticons(); + + // Fix any invalid nesting, e.g. if a quote or other block is inserted + // into a paragraph + fixNesting(wysiwygBody); + + // Scroll the editor after the end of the selection + marker = find(wysiwygBody, '#sceditor-end-marker')[0]; + show(marker); + scrollTop = wysiwygBody.scrollTop; + scrollTo = (getOffset(marker).top + + (marker.offsetHeight * 1.5)) - editorHeight; + hide(marker); + + // Only scroll if marker isn't already visible + if (scrollTo > scrollTop || scrollTo + editorHeight < scrollTop) { + wysiwygBody.scrollTop = scrollTo; + } + + triggerValueChanged(false); + rangeHelper.restoreRange(); + + // Add a new line after the last block element + // so can always add text after it + appendNewLine(); + }; + + /** + * Like wysiwygEditorInsertHtml except it will convert any HTML + * into text before inserting it. + * + * @param {string} text + * @param {string} [endText=null] + * @function + * @name wysiwygEditorInsertText + * @memberOf SCEditor.prototype + */ + base.wysiwygEditorInsertText = function (text, endText) { + base.wysiwygEditorInsertHtml( + entities(text), entities(endText) + ); + }; + + /** + * Inserts text into the WYSIWYG or source editor depending on which + * mode the editor is in. + * + * If endText is specified any selected text will be placed between + * text and endText. If no text is selected text and endText will + * just be concatenate together. + * + * @param {string} text + * @param {string} [endText=null] + * @since 1.3.5 + * @function + * @name insertText + * @memberOf SCEditor.prototype + */ + base.insertText = function (text, endText) { + if (base.inSourceMode()) { + base.sourceEditorInsertText(text, endText); + } else { + base.wysiwygEditorInsertText(text, endText); + } + + return base; + }; + + /** + * Like wysiwygEditorInsertHtml but inserts text into the + * source mode editor instead. + * + * If endText is specified any selected text will be placed between + * text and endText. If no text is selected text and endText will + * just be concatenate together. + * + * The cursor will be placed after the text param. If endText is + * specified the cursor will be placed before endText, so passing:<br /> + * + * '[b]', '[/b]' + * + * Would cause the cursor to be placed:<br /> + * + * [b]Selected text|[/b] + * + * @param {string} text + * @param {string} [endText=null] + * @since 1.4.0 + * @function + * @name sourceEditorInsertText + * @memberOf SCEditor.prototype + */ + base.sourceEditorInsertText = function (text, endText) { + var scrollTop, currentValue, + startPos = sourceEditor.selectionStart, + endPos = sourceEditor.selectionEnd; + + scrollTop = sourceEditor.scrollTop; + sourceEditor.focus(); + currentValue = sourceEditor.value; + + if (endText) { + text += currentValue.substring(startPos, endPos) + endText; + } + + sourceEditor.value = currentValue.substring(0, startPos) + + text + + currentValue.substring(endPos, currentValue.length); + + sourceEditor.selectionStart = (startPos + text.length) - + (endText ? endText.length : 0); + sourceEditor.selectionEnd = sourceEditor.selectionStart; + + sourceEditor.scrollTop = scrollTop; + sourceEditor.focus(); + + triggerValueChanged(); + }; + + /** + * Gets the current instance of the rangeHelper class + * for the editor. + * + * @return {RangeHelper} + * @function + * @name getRangeHelper + * @memberOf SCEditor.prototype + */ + base.getRangeHelper = function () { + return rangeHelper; + }; + + /** + * Gets or sets the source editor caret position. + * + * @param {Object} [position] + * @return {this} + * @function + * @since 1.4.5 + * @name sourceEditorCaret + * @memberOf SCEditor.prototype + */ + base.sourceEditorCaret = function (position) { + sourceEditor.focus(); + + if (position) { + sourceEditor.selectionStart = position.start; + sourceEditor.selectionEnd = position.end; + + return this; + } + + return { + start: sourceEditor.selectionStart, + end: sourceEditor.selectionEnd + }; + }; + + /** + * Gets the value of the editor. + * + * If the editor is in WYSIWYG mode it will return the filtered + * HTML from it (converted to BBCode if using the BBCode plugin). + * It it's in Source Mode it will return the unfiltered contents + * of the source editor (if using the BBCode plugin this will be + * BBCode again). + * + * @since 1.3.5 + * @return {string} + * @function + * @name val + * @memberOf SCEditor.prototype + */ + /** + * Sets the value of the editor. + * + * If filter set true the val will be passed through the filter + * function. If using the BBCode plugin it will pass the val to + * the BBCode filter to convert any BBCode into HTML. + * + * @param {string} val + * @param {boolean} [filter=true] + * @return {this} + * @since 1.3.5 + * @function + * @name val^2 + * @memberOf SCEditor.prototype + */ + base.val = function (val, filter) { + if (!isString(val)) { + return base.inSourceMode() ? + base.getSourceEditorValue(false) : + base.getWysiwygEditorValue(filter); + } + + if (!base.inSourceMode()) { + if (filter !== false && 'toHtml' in format) { + val = format.toHtml(val); + } + + base.setWysiwygEditorValue(val); + } else { + base.setSourceEditorValue(val); + } + + return base; + }; + + /** + * Inserts HTML/BBCode into the editor + * + * If end is supplied any selected text will be placed between + * start and end. If there is no selected text start and end + * will be concatenate together. + * + * If the filter param is set to true, the HTML/BBCode will be + * passed through any plugin filters. If using the BBCode plugin + * this will convert any BBCode into HTML. + * + * @param {string} start + * @param {string} [end=null] + * @param {boolean} [filter=true] + * @param {boolean} [convertEmoticons=true] If to convert emoticons + * @return {this} + * @since 1.3.5 + * @function + * @name insert + * @memberOf SCEditor.prototype + */ + /** + * Inserts HTML/BBCode into the editor + * + * If end is supplied any selected text will be placed between + * start and end. If there is no selected text start and end + * will be concatenate together. + * + * If the filter param is set to true, the HTML/BBCode will be + * passed through any plugin filters. If using the BBCode plugin + * this will convert any BBCode into HTML. + * + * If the allowMixed param is set to true, HTML any will not be + * escaped + * + * @param {string} start + * @param {string} [end=null] + * @param {boolean} [filter=true] + * @param {boolean} [convertEmoticons=true] If to convert emoticons + * @param {boolean} [allowMixed=false] + * @return {this} + * @since 1.4.3 + * @function + * @name insert^2 + * @memberOf SCEditor.prototype + */ + // eslint-disable-next-line max-params + base.insert = function ( + start, end, filter, convertEmoticons, allowMixed + ) { + if (base.inSourceMode()) { + base.sourceEditorInsertText(start, end); + return base; + } + + // Add the selection between start and end + if (end) { + var html = rangeHelper.selectedHtml(); + + if (filter !== false && 'fragmentToSource' in format) { + html = format + .fragmentToSource(html, wysiwygDocument, currentNode); + } + + start += html + end; + } + // TODO: This filter should allow empty tags as it's inserting. + if (filter !== false && 'fragmentToHtml' in format) { + start = format.fragmentToHtml(start, currentNode); + } + + // Convert any escaped HTML back into HTML if mixed is allowed + if (filter !== false && allowMixed === true) { + start = start.replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); + } + + base.wysiwygEditorInsertHtml(start); + + return base; + }; + + /** + * Gets the WYSIWYG editors HTML value. + * + * If using a plugin that filters the Ht Ml like the BBCode plugin + * it will return the result of the filtering (BBCode) unless the + * filter param is set to false. + * + * @param {boolean} [filter=true] + * @return {string} + * @function + * @name getWysiwygEditorValue + * @memberOf SCEditor.prototype + */ + base.getWysiwygEditorValue = function (filter) { + var html; + // Create a tmp node to store contents so it can be modified + // without affecting anything else. + var tmp = createElement('div', {}, wysiwygDocument); + var childNodes = wysiwygBody.childNodes; + + for (var i = 0; i < childNodes.length; i++) { + appendChild(tmp, childNodes[i].cloneNode(true)); + } + + appendChild(wysiwygBody, tmp); + fixNesting(tmp); + remove(tmp); + + html = tmp.innerHTML; + + // filter the HTML and DOM through any plugins + if (filter !== false && format.hasOwnProperty('toSource')) { + html = format.toSource(html, wysiwygDocument); + } + + return html; + }; + + /** + * Gets the WYSIWYG editor's iFrame Body. + * + * @return {HTMLElement} + * @function + * @since 1.4.3 + * @name getBody + * @memberOf SCEditor.prototype + */ + base.getBody = function () { + return wysiwygBody; + }; + + /** + * Gets the WYSIWYG editors container area (whole iFrame). + * + * @return {HTMLElement} + * @function + * @since 1.4.3 + * @name getContentAreaContainer + * @memberOf SCEditor.prototype + */ + base.getContentAreaContainer = function () { + return wysiwygEditor; + }; + + /** + * Gets the text editor value + * + * If using a plugin that filters the text like the BBCode plugin + * it will return the result of the filtering which is BBCode to + * HTML so it will return HTML. If filter is set to false it will + * just return the contents of the source editor (BBCode). + * + * @param {boolean} [filter=true] + * @return {string} + * @function + * @since 1.4.0 + * @name getSourceEditorValue + * @memberOf SCEditor.prototype + */ + base.getSourceEditorValue = function (filter) { + var val = sourceEditor.value; + + if (filter !== false && 'toHtml' in format) { + val = format.toHtml(val); + } + + return val; + }; + + /** + * Sets the WYSIWYG HTML editor value. Should only be the HTML + * contained within the body tags + * + * @param {string} value + * @function + * @name setWysiwygEditorValue + * @memberOf SCEditor.prototype + */ + base.setWysiwygEditorValue = function (value) { + if (!value) { + value = '<p><br /></p>'; + } + + wysiwygBody.innerHTML = sanitize(value); + replaceEmoticons(); + + appendNewLine(); + triggerValueChanged(); + autoExpand(); + }; + + /** + * Sets the text editor value + * + * @param {string} value + * @function + * @name setSourceEditorValue + * @memberOf SCEditor.prototype + */ + base.setSourceEditorValue = function (value) { + sourceEditor.value = value; + + triggerValueChanged(); + }; + + /** + * Updates the textarea that the editor is replacing + * with the value currently inside the editor. + * + * @function + * @name updateOriginal + * @since 1.4.0 + * @memberOf SCEditor.prototype + */ + base.updateOriginal = function () { + original.value = base.val(); + }; + + /** + * Replaces any emoticon codes in the passed HTML + * with their emoticon images + * @private + */ + replaceEmoticons = function () { + if (options.emoticonsEnabled) { + replace(wysiwygBody, allEmoticons, options.emoticonsCompat); + } + }; + + /** + * If the editor is in source code mode + * + * @return {boolean} + * @function + * @name inSourceMode + * @memberOf SCEditor.prototype + */ + base.inSourceMode = function () { + return hasClass(editorContainer, 'sourceMode'); + }; + + /** + * Gets if the editor is in sourceMode + * + * @return boolean + * @function + * @name sourceMode + * @memberOf SCEditor.prototype + */ + /** + * Sets if the editor is in sourceMode + * + * @param {boolean} enable + * @return {this} + * @function + * @name sourceMode^2 + * @memberOf SCEditor.prototype + */ + base.sourceMode = function (enable) { + var inSourceMode = base.inSourceMode(); + + if (typeof enable !== 'boolean') { + return inSourceMode; + } + + if ((inSourceMode && !enable) || (!inSourceMode && enable)) { + base.toggleSourceMode(); + } + + return base; + }; + + /** + * Switches between the WYSIWYG and source modes + * + * @function + * @name toggleSourceMode + * @since 1.4.0 + * @memberOf SCEditor.prototype + */ + base.toggleSourceMode = function () { + var isInSourceMode = base.inSourceMode(); + + // don't allow switching to WYSIWYG if doesn't support it + if (!isWysiwygSupported && isInSourceMode) { + return; + } + + if (!isInSourceMode) { + rangeHelper.saveRange(); + rangeHelper.clear(); + } + + currentSelection = null; + base.blur(); + + if (isInSourceMode) { + base.setWysiwygEditorValue(base.getSourceEditorValue()); + } else { + base.setSourceEditorValue(base.getWysiwygEditorValue()); + } + + toggle(sourceEditor); + toggle(wysiwygEditor); + + toggleClass(editorContainer, 'wysiwygMode', isInSourceMode); + toggleClass(editorContainer, 'sourceMode', !isInSourceMode); + + updateToolBar(); + updateActiveButtons(); + }; + + /** + * Gets the selected text of the source editor + * @return {string} + * @private + */ + sourceEditorSelectedText = function () { + sourceEditor.focus(); + + return sourceEditor.value.substring( + sourceEditor.selectionStart, + sourceEditor.selectionEnd + ); + }; + + /** + * Handles the passed command + * @private + */ + handleCommand = function (caller, cmd) { + // check if in text mode and handle text commands + if (base.inSourceMode()) { + if (cmd.txtExec) { + if (Array.isArray(cmd.txtExec)) { + base.sourceEditorInsertText.apply(base, cmd.txtExec); + } else { + cmd.txtExec.call(base, caller, sourceEditorSelectedText()); + } + } + } else if (cmd.exec) { + if (isFunction(cmd.exec)) { + cmd.exec.call(base, caller); + } else { + base.execCommand( + cmd.exec, + cmd.hasOwnProperty('execParam') ? cmd.execParam : null + ); + } + } + + }; + + /** + * Executes a command on the WYSIWYG editor + * + * @param {string} command + * @param {String|Boolean} [param] + * @function + * @name execCommand + * @memberOf SCEditor.prototype + */ + base.execCommand = function (command, param) { + var executed = false, + commandObj = base.commands[command]; + + base.focus(); + + // TODO: make configurable + // don't apply any commands to code elements + if (closest(rangeHelper.parentNode(), 'code')) { + return; + } + + try { + executed = wysiwygDocument.execCommand(command, false, param); + } catch (ex) { } + + // show error if execution failed and an error message exists + if (!executed && commandObj && commandObj.errorMessage) { + /*global alert:false*/ + alert(base._(commandObj.errorMessage)); + } + + updateActiveButtons(); + }; + + /** + * Checks if the current selection has changed and triggers + * the selectionchanged event if it has. + * + * In browsers other that don't support selectionchange event it will check + * at most once every 100ms. + * @private + */ + checkSelectionChanged = function () { + function check() { + // Don't create new selection if there isn't one (like after + // blur event in iOS) + if (wysiwygWindow.getSelection() && + wysiwygWindow.getSelection().rangeCount <= 0) { + currentSelection = null; + // rangeHelper could be null if editor was destroyed + // before the timeout had finished + } else if (rangeHelper && !rangeHelper.compare(currentSelection)) { + currentSelection = rangeHelper.cloneSelected(); + + // If the selection is in an inline wrap it in a block. + // Fixes #331 + if (currentSelection && currentSelection.collapsed) { + var parent = currentSelection.startContainer; + var offset = currentSelection.startOffset; + + // Handle if selection is placed before/after an element + if (offset && parent.nodeType !== TEXT_NODE) { + parent = parent.childNodes[offset]; + } + + while (parent && parent.parentNode !== wysiwygBody) { + parent = parent.parentNode; + } + + if (parent && isInline(parent, true)) { + rangeHelper.saveRange(); + wrapInlines(wysiwygBody, wysiwygDocument); + rangeHelper.restoreRange(); + } + } + + trigger(editorContainer, 'selectionchanged'); + } + + isSelectionCheckPending = false; + } + + if (isSelectionCheckPending) { + return; + } + + isSelectionCheckPending = true; + + // Don't need to limit checking if browser supports the Selection API + if ('onselectionchange' in wysiwygDocument) { + check(); + } else { + setTimeout(check, 100); + } + }; + + /** + * Checks if the current node has changed and triggers + * the nodechanged event if it has + * @private + */ + checkNodeChanged = function () { + // check if node has changed + var oldNode, + node = rangeHelper.parentNode(); + + if (currentNode !== node) { + oldNode = currentNode; + currentNode = node; + currentBlockNode = rangeHelper.getFirstBlockParent(node); + + trigger(editorContainer, 'nodechanged', { + oldNode: oldNode, + newNode: currentNode + }); + } + }; + + /** + * Gets the current node that contains the selection/caret in + * WYSIWYG mode. + * + * Will be null in sourceMode or if there is no selection. + * + * @return {?Node} + * @function + * @name currentNode + * @memberOf SCEditor.prototype + */ + base.currentNode = function () { + return currentNode; + }; + + /** + * Gets the first block level node that contains the + * selection/caret in WYSIWYG mode. + * + * Will be null in sourceMode or if there is no selection. + * + * @return {?Node} + * @function + * @name currentBlockNode + * @memberOf SCEditor.prototype + * @since 1.4.4 + */ + base.currentBlockNode = function () { + return currentBlockNode; + }; + + /** + * Updates if buttons are active or not + * @private + */ + updateActiveButtons = function () { + var firstBlock, parent; + var activeClass = 'active'; + var doc = wysiwygDocument; + var isSource = base.sourceMode(); + + if (base.readOnly()) { + each(find(toolbar, activeClass), function (_, menuItem) { + removeClass(menuItem, activeClass); + }); + return; + } + + if (!isSource) { + parent = rangeHelper.parentNode(); + firstBlock = rangeHelper.getFirstBlockParent(parent); + } + + for (var j = 0; j < btnStateHandlers.length; j++) { + var state = 0; + var btn = toolbarButtons[btnStateHandlers[j].name]; + var stateFn = btnStateHandlers[j].state; + var isDisabled = (isSource && !btn._sceTxtMode) || + (!isSource && !btn._sceWysiwygMode); + + if (isString(stateFn)) { + if (!isSource) { + try { + state = doc.queryCommandEnabled(stateFn) ? 0 : -1; + + // eslint-disable-next-line max-depth + if (state > -1) { + state = doc.queryCommandState(stateFn) ? 1 : 0; + } + } catch (ex) {} + } + } else if (!isDisabled) { + state = stateFn.call(base, parent, firstBlock); + } + + toggleClass(btn, 'disabled', isDisabled || state < 0); + toggleClass(btn, activeClass, state > 0); + } + + if (icons && icons.update) { + icons.update(isSource, parent, firstBlock); + } + }; + + /** + * Handles any key press in the WYSIWYG editor + * + * @private + */ + handleKeyPress = function (e) { + // FF bug: https://bugzilla.mozilla.org/show_bug.cgi?id=501496 + if (e.defaultPrevented) { + return; + } + + base.closeDropDown(); + + // 13 = enter key + if (e.which === 13) { + var LIST_TAGS = 'li,ul,ol'; + + // "Fix" (cludge) for blocklevel elements being duplicated in some + // browsers when enter is pressed instead of inserting a newline + if (!is(currentBlockNode, LIST_TAGS) && + hasStyling(currentBlockNode)) { + + var br = createElement('br', {}, wysiwygDocument); + rangeHelper.insertNode(br); + + // Last <br> of a block will be collapsed so need to make sure + // the <br> that was inserted isn't the last node of a block. + var parent = br.parentNode; + var lastChild = parent.lastChild; + + // Sometimes an empty next node is created after the <br> + if (lastChild && lastChild.nodeType === TEXT_NODE && + lastChild.nodeValue === '') { + remove(lastChild); + lastChild = parent.lastChild; + } + + // If this is the last BR of a block and the previous + // sibling is inline then will need an extra BR. This + // is needed because the last BR of a block will be + // collapsed. Fixes issue #248 + if (!isInline(parent, true) && lastChild === br && + isInline(br.previousSibling)) { + rangeHelper.insertHTML('<br>'); + } + + e.preventDefault(); + } + } + }; + + /** + * Makes sure that if there is a code or quote tag at the + * end of the editor, that there is a new line after it. + * + * If there wasn't a new line at the end you wouldn't be able + * to enter any text after a code/quote tag + * @return {void} + * @private + */ + appendNewLine = function () { + // Check all nodes in reverse until either add a new line + // or reach a non-empty textnode or BR at which point can + // stop checking. + rTraverse(wysiwygBody, function (node) { + // Last block, add new line after if has styling + if (node.nodeType === ELEMENT_NODE && + !/inline/.test(css(node, 'display'))) { + + // Add line break after if has styling + if (!is(node, '.sceditor-nlf') && hasStyling(node)) { + var paragraph = createElement('p', {}, wysiwygDocument); + paragraph.className = 'sceditor-nlf'; + paragraph.innerHTML = '<br />'; + appendChild(wysiwygBody, paragraph); + return false; + } + } + + // Last non-empty text node or line break. + // No need to add line-break after them + if ((node.nodeType === 3 && !/^\s*$/.test(node.nodeValue)) || + is(node, 'br')) { + return false; + } + }); + }; + + /** + * Handles form reset event + * @private + */ + handleFormReset = function () { + base.val(original.value); + }; + + /** + * Handles any mousedown press in the WYSIWYG editor + * @private + */ + handleMouseDown = function () { + base.closeDropDown(); + }; + + /** + * Translates the string into the locale language. + * + * Replaces any {0}, {1}, {2}, ect. with the params provided. + * + * @param {string} str + * @param {...String} args + * @return {string} + * @function + * @name _ + * @memberOf SCEditor.prototype + */ + base._ = function () { + var undef, + args = arguments; + + if (locale && locale[args[0]]) { + args[0] = locale[args[0]]; + } + + return args[0].replace(/\{(\d+)\}/g, function (str, p1) { + return args[p1 - 0 + 1] !== undef ? + args[p1 - 0 + 1] : + '{' + p1 + '}'; + }); + }; + + /** + * Passes events on to any handlers + * @private + * @return void + */ + handleEvent = function (e) { + if (pluginManager) { + // Send event to all plugins + pluginManager.call(e.type + 'Event', e, base); + } + + // convert the event into a custom event to send + var name = (e.target === sourceEditor ? 'scesrc' : 'scewys') + e.type; + + if (eventHandlers[name]) { + eventHandlers[name].forEach(function (fn) { + fn.call(base, e); + }); + } + }; + + /** + * Binds a handler to the specified events + * + * This function only binds to a limited list of + * supported events. + * + * The supported events are: + * + * * keyup + * * keydown + * * Keypress + * * blur + * * focus + * * input + * * nodechanged - When the current node containing + * the selection changes in WYSIWYG mode + * * contextmenu + * * selectionchanged + * * valuechanged + * + * + * The events param should be a string containing the event(s) + * to bind this handler to. If multiple, they should be separated + * by spaces. + * + * @param {string} events + * @param {Function} handler + * @param {boolean} excludeWysiwyg If to exclude adding this handler + * to the WYSIWYG editor + * @param {boolean} excludeSource if to exclude adding this handler + * to the source editor + * @return {this} + * @function + * @name bind + * @memberOf SCEditor.prototype + * @since 1.4.1 + */ + base.bind = function (events, handler, excludeWysiwyg, excludeSource) { + events = events.split(' '); + + var i = events.length; + while (i--) { + if (isFunction(handler)) { + var wysEvent = 'scewys' + events[i]; + var srcEvent = 'scesrc' + events[i]; + // Use custom events to allow passing the instance as the + // 2nd argument. + // Also allows unbinding without unbinding the editors own + // event handlers. + if (!excludeWysiwyg) { + eventHandlers[wysEvent] = eventHandlers[wysEvent] || []; + eventHandlers[wysEvent].push(handler); + } + + if (!excludeSource) { + eventHandlers[srcEvent] = eventHandlers[srcEvent] || []; + eventHandlers[srcEvent].push(handler); + } + + // Start sending value changed events + if (events[i] === 'valuechanged') { + triggerValueChanged.hasHandler = true; + } + } + } + + return base; + }; + + /** + * Unbinds an event that was bound using bind(). + * + * @param {string} events + * @param {Function} handler + * @param {boolean} excludeWysiwyg If to exclude unbinding this + * handler from the WYSIWYG editor + * @param {boolean} excludeSource if to exclude unbinding this + * handler from the source editor + * @return {this} + * @function + * @name unbind + * @memberOf SCEditor.prototype + * @since 1.4.1 + * @see bind + */ + base.unbind = function (events, handler, excludeWysiwyg, excludeSource) { + events = events.split(' '); + + var i = events.length; + while (i--) { + if (isFunction(handler)) { + if (!excludeWysiwyg) { + arrayRemove( + eventHandlers['scewys' + events[i]] || [], handler); + } + + if (!excludeSource) { + arrayRemove( + eventHandlers['scesrc' + events[i]] || [], handler); + } + } + } + + return base; + }; + + /** + * Blurs the editors input area + * + * @return {this} + * @function + * @name blur + * @memberOf SCEditor.prototype + * @since 1.3.6 + */ + /** + * Adds a handler to the editors blur event + * + * @param {Function} handler + * @param {boolean} excludeWysiwyg If to exclude adding this handler + * to the WYSIWYG editor + * @param {boolean} excludeSource if to exclude adding this handler + * to the source editor + * @return {this} + * @function + * @name blur^2 + * @memberOf SCEditor.prototype + * @since 1.4.1 + */ + base.blur = function (handler, excludeWysiwyg, excludeSource) { + if (isFunction(handler)) { + base.bind('blur', handler, excludeWysiwyg, excludeSource); + } else if (!base.sourceMode()) { + wysiwygBody.blur(); + } else { + sourceEditor.blur(); + } + + return base; + }; + + /** + * Focuses the editors input area + * + * @return {this} + * @function + * @name focus + * @memberOf SCEditor.prototype + */ + /** + * Adds an event handler to the focus event + * + * @param {Function} handler + * @param {boolean} excludeWysiwyg If to exclude adding this handler + * to the WYSIWYG editor + * @param {boolean} excludeSource if to exclude adding this handler + * to the source editor + * @return {this} + * @function + * @name focus^2 + * @memberOf SCEditor.prototype + * @since 1.4.1 + */ + base.focus = function (handler, excludeWysiwyg, excludeSource) { + if (isFunction(handler)) { + base.bind('focus', handler, excludeWysiwyg, excludeSource); + } else if (!base.inSourceMode()) { + // Already has focus so do nothing + if (find(wysiwygDocument, ':focus').length) { + return; + } + + var container; + var rng = rangeHelper.selectedRange(); + + // Fix FF bug where it shows the cursor in the wrong place + // if the editor hasn't had focus before. See issue #393 + if (!currentSelection) { + autofocus(true); + } + + // Check if cursor is set after a BR when the BR is the only + // child of the parent. In Firefox this causes a line break + // to occur when something is typed. See issue #321 + if (rng && rng.endOffset === 1 && rng.collapsed) { + container = rng.endContainer; + + if (container && container.childNodes.length === 1 && + is(container.firstChild, 'br')) { + rng.setStartBefore(container.firstChild); + rng.collapse(true); + rangeHelper.selectRange(rng); + } + } + + wysiwygWindow.focus(); + wysiwygBody.focus(); + } else { + sourceEditor.focus(); + } + + updateActiveButtons(); + + return base; + }; + + /** + * Adds a handler to the key down event + * + * @param {Function} handler + * @param {boolean} excludeWysiwyg If to exclude adding this handler + * to the WYSIWYG editor + * @param {boolean} excludeSource If to exclude adding this handler + * to the source editor + * @return {this} + * @function + * @name keyDown + * @memberOf SCEditor.prototype + * @since 1.4.1 + */ + base.keyDown = function (handler, excludeWysiwyg, excludeSource) { + return base.bind('keydown', handler, excludeWysiwyg, excludeSource); + }; + + /** + * Adds a handler to the key press event + * + * @param {Function} handler + * @param {boolean} excludeWysiwyg If to exclude adding this handler + * to the WYSIWYG editor + * @param {boolean} excludeSource If to exclude adding this handler + * to the source editor + * @return {this} + * @function + * @name keyPress + * @memberOf SCEditor.prototype + * @since 1.4.1 + */ + base.keyPress = function (handler, excludeWysiwyg, excludeSource) { + return base + .bind('keypress', handler, excludeWysiwyg, excludeSource); + }; + + /** + * Adds a handler to the key up event + * + * @param {Function} handler + * @param {boolean} excludeWysiwyg If to exclude adding this handler + * to the WYSIWYG editor + * @param {boolean} excludeSource If to exclude adding this handler + * to the source editor + * @return {this} + * @function + * @name keyUp + * @memberOf SCEditor.prototype + * @since 1.4.1 + */ + base.keyUp = function (handler, excludeWysiwyg, excludeSource) { + return base.bind('keyup', handler, excludeWysiwyg, excludeSource); + }; + + /** + * Adds a handler to the node changed event. + * + * Happens whenever the node containing the selection/caret + * changes in WYSIWYG mode. + * + * @param {Function} handler + * @return {this} + * @function + * @name nodeChanged + * @memberOf SCEditor.prototype + * @since 1.4.1 + */ + base.nodeChanged = function (handler) { + return base.bind('nodechanged', handler, false, true); + }; + + /** + * Adds a handler to the selection changed event + * + * Happens whenever the selection changes in WYSIWYG mode. + * + * @param {Function} handler + * @return {this} + * @function + * @name selectionChanged + * @memberOf SCEditor.prototype + * @since 1.4.1 + */ + base.selectionChanged = function (handler) { + return base.bind('selectionchanged', handler, false, true); + }; + + /** + * Adds a handler to the value changed event + * + * Happens whenever the current editor value changes. + * + * Whenever anything is inserted, the value changed or + * 1.5 secs after text is typed. If a space is typed it will + * cause the event to be triggered immediately instead of + * after 1.5 seconds + * + * @param {Function} handler + * @param {boolean} excludeWysiwyg If to exclude adding this handler + * to the WYSIWYG editor + * @param {boolean} excludeSource If to exclude adding this handler + * to the source editor + * @return {this} + * @function + * @name valueChanged + * @memberOf SCEditor.prototype + * @since 1.4.5 + */ + base.valueChanged = function (handler, excludeWysiwyg, excludeSource) { + return base + .bind('valuechanged', handler, excludeWysiwyg, excludeSource); + }; + + /** + * Emoticons keypress handler + * @private + */ + emoticonsKeyPress = function (e) { + var replacedEmoticon, + cachePos = 0, + emoticonsCache = base.emoticonsCache, + curChar = String.fromCharCode(e.which); + + // TODO: Make configurable + if (closest(currentBlockNode, 'code')) { + return; + } + + if (!emoticonsCache) { + emoticonsCache = []; + + each(allEmoticons, function (key, html) { + emoticonsCache[cachePos++] = [key, html]; + }); + + emoticonsCache.sort(function (a, b) { + return a[0].length - b[0].length; + }); + + base.emoticonsCache = emoticonsCache; + base.longestEmoticonCode = + emoticonsCache[emoticonsCache.length - 1][0].length; + } + + replacedEmoticon = rangeHelper.replaceKeyword( + base.emoticonsCache, + true, + true, + base.longestEmoticonCode, + options.emoticonsCompat, + curChar + ); + + if (replacedEmoticon) { + if (!options.emoticonsCompat || !/^\s$/.test(curChar)) { + e.preventDefault(); + } + } + }; + + /** + * Makes sure emoticons are surrounded by whitespace + * @private + */ + emoticonsCheckWhitespace = function () { + checkWhitespace(currentBlockNode, rangeHelper); + }; + + /** + * Gets if emoticons are currently enabled + * @return {boolean} + * @function + * @name emoticons + * @memberOf SCEditor.prototype + * @since 1.4.2 + */ + /** + * Enables/disables emoticons + * + * @param {boolean} enable + * @return {this} + * @function + * @name emoticons^2 + * @memberOf SCEditor.prototype + * @since 1.4.2 + */ + base.emoticons = function (enable) { + if (!enable && enable !== false) { + return options.emoticonsEnabled; + } + + options.emoticonsEnabled = enable; + + if (enable) { + on(wysiwygBody, 'keypress', emoticonsKeyPress); + + if (!base.sourceMode()) { + rangeHelper.saveRange(); + + replaceEmoticons(); + triggerValueChanged(false); + + rangeHelper.restoreRange(); + } + } else { + var emoticons = + find(wysiwygBody, 'img[data-sceditor-emoticon]'); + + each(emoticons, function (_, img) { + var text = data(img, 'sceditor-emoticon'); + var textNode = wysiwygDocument.createTextNode(text); + img.parentNode.replaceChild(textNode, img); + }); + + off(wysiwygBody, 'keypress', emoticonsKeyPress); + + triggerValueChanged(); + } + + return base; + }; + + /** + * Gets the current WYSIWYG editors inline CSS + * + * @return {string} + * @function + * @name css + * @memberOf SCEditor.prototype + * @since 1.4.3 + */ + /** + * Sets inline CSS for the WYSIWYG editor + * + * @param {string} css + * @return {this} + * @function + * @name css^2 + * @memberOf SCEditor.prototype + * @since 1.4.3 + */ + base.css = function (css) { + if (!inlineCss) { + inlineCss = createElement('style', { + id: 'inline' + }, wysiwygDocument); + + appendChild(wysiwygDocument.head, inlineCss); + } + + if (!isString(css)) { + return inlineCss.styleSheet ? + inlineCss.styleSheet.cssText : inlineCss.innerHTML; + } + + if (inlineCss.styleSheet) { + inlineCss.styleSheet.cssText = css; + } else { + inlineCss.innerHTML = css; + } + + return base; + }; + + /** + * Handles the keydown event, used for shortcuts + * @private + */ + handleKeyDown = function (e) { + var shortcut = [], + SHIFT_KEYS = { + '`': '~', + '1': '!', + '2': '@', + '3': '#', + '4': '$', + '5': '%', + '6': '^', + '7': '&', + '8': '*', + '9': '(', + '0': ')', + '-': '_', + '=': '+', + ';': ': ', + '\'': '"', + ',': '<', + '.': '>', + '/': '?', + '\\': '|', + '[': '{', + ']': '}' + }, + SPECIAL_KEYS = { + 8: 'backspace', + 9: 'tab', + 13: 'enter', + 19: 'pause', + 20: 'capslock', + 27: 'esc', + 32: 'space', + 33: 'pageup', + 34: 'pagedown', + 35: 'end', + 36: 'home', + 37: 'left', + 38: 'up', + 39: 'right', + 40: 'down', + 45: 'insert', + 46: 'del', + 91: 'win', + 92: 'win', + 93: 'select', + 96: '0', + 97: '1', + 98: '2', + 99: '3', + 100: '4', + 101: '5', + 102: '6', + 103: '7', + 104: '8', + 105: '9', + 106: '*', + 107: '+', + 109: '-', + 110: '.', + 111: '/', + 112: 'f1', + 113: 'f2', + 114: 'f3', + 115: 'f4', + 116: 'f5', + 117: 'f6', + 118: 'f7', + 119: 'f8', + 120: 'f9', + 121: 'f10', + 122: 'f11', + 123: 'f12', + 144: 'numlock', + 145: 'scrolllock', + 186: ';', + 187: '=', + 188: ',', + 189: '-', + 190: '.', + 191: '/', + 192: '`', + 219: '[', + 220: '\\', + 221: ']', + 222: '\'' + }, + NUMPAD_SHIFT_KEYS = { + 109: '-', + 110: 'del', + 111: '/', + 96: '0', + 97: '1', + 98: '2', + 99: '3', + 100: '4', + 101: '5', + 102: '6', + 103: '7', + 104: '8', + 105: '9' + }, + which = e.which, + character = SPECIAL_KEYS[which] || + String.fromCharCode(which).toLowerCase(); + + if (e.ctrlKey || e.metaKey) { + shortcut.push('ctrl'); + } + + if (e.altKey) { + shortcut.push('alt'); + } + + if (e.shiftKey) { + shortcut.push('shift'); + + if (NUMPAD_SHIFT_KEYS[which]) { + character = NUMPAD_SHIFT_KEYS[which]; + } else if (SHIFT_KEYS[character]) { + character = SHIFT_KEYS[character]; + } + } + + // Shift is 16, ctrl is 17 and alt is 18 + if (character && (which < 16 || which > 18)) { + shortcut.push(character); + } + + shortcut = shortcut.join('+'); + if (shortcutHandlers[shortcut] && + shortcutHandlers[shortcut].call(base) === false) { + + e.stopPropagation(); + e.preventDefault(); + } + }; + + /** + * Adds a shortcut handler to the editor + * @param {string} shortcut + * @param {String|Function} cmd + * @return {sceditor} + */ + base.addShortcut = function (shortcut, cmd) { + shortcut = shortcut.toLowerCase(); + + if (isString(cmd)) { + shortcutHandlers[shortcut] = function () { + handleCommand(toolbarButtons[cmd], base.commands[cmd]); + + return false; + }; + } else { + shortcutHandlers[shortcut] = cmd; + } + + return base; + }; + + /** + * Removes a shortcut handler + * @param {string} shortcut + * @return {sceditor} + */ + base.removeShortcut = function (shortcut) { + delete shortcutHandlers[shortcut.toLowerCase()]; + + return base; + }; + + /** + * Handles the backspace key press + * + * Will remove block styling like quotes/code ect if at the start. + * @private + */ + handleBackSpace = function (e) { + var node, offset, range, parent; + + // 8 is the backspace key + if (options.disableBlockRemove || e.which !== 8 || + !(range = rangeHelper.selectedRange())) { + return; + } + + node = range.startContainer; + offset = range.startOffset; + + if (offset !== 0 || !(parent = currentStyledBlockNode()) || + is(parent, 'body')) { + return; + } + + while (node !== parent) { + while (node.previousSibling) { + node = node.previousSibling; + + // Everything but empty text nodes before the cursor + // should prevent the style from being removed + if (node.nodeType !== TEXT_NODE || node.nodeValue) { + return; + } + } + + if (!(node = node.parentNode)) { + return; + } + } + + // The backspace was pressed at the start of + // the container so clear the style + base.clearBlockFormatting(parent); + e.preventDefault(); + }; + + /** + * Gets the first styled block node that contains the cursor + * @return {HTMLElement} + */ + currentStyledBlockNode = function () { + var block = currentBlockNode; + + while (!hasStyling(block) || isInline(block, true)) { + if (!(block = block.parentNode) || is(block, 'body')) { + return; + } + } + + return block; + }; + + /** + * Clears the formatting of the passed block element. + * + * If block is false, if will clear the styling of the first + * block level element that contains the cursor. + * @param {HTMLElement} block + * @since 1.4.4 + */ + base.clearBlockFormatting = function (block) { + block = block || currentStyledBlockNode(); + + if (!block || is(block, 'body')) { + return base; + } + + rangeHelper.saveRange(); + + block.className = ''; + + attr(block, 'style', ''); + + if (!is(block, 'p,div,td')) { + convertElement(block, 'p'); + } + + rangeHelper.restoreRange(); + return base; + }; + + /** + * Triggers the valueChanged signal if there is + * a plugin that handles it. + * + * If rangeHelper.saveRange() has already been + * called, then saveRange should be set to false + * to prevent the range being saved twice. + * + * @since 1.4.5 + * @param {boolean} saveRange If to call rangeHelper.saveRange(). + * @private + */ + triggerValueChanged = function (saveRange) { + if (!pluginManager || + (!pluginManager.hasHandler('valuechangedEvent') && + !triggerValueChanged.hasHandler)) { + return; + } + + var currentHtml, + sourceMode = base.sourceMode(), + hasSelection = !sourceMode && rangeHelper.hasSelection(); + + // Composition end isn't guaranteed to fire but must have + // ended when triggerValueChanged() is called so reset it + isComposing = false; + + // Don't need to save the range if sceditor-start-marker + // is present as the range is already saved + saveRange = saveRange !== false && + !wysiwygDocument.getElementById('sceditor-start-marker'); + + // Clear any current timeout as it's now been triggered + if (valueChangedKeyUpTimer) { + clearTimeout(valueChangedKeyUpTimer); + valueChangedKeyUpTimer = false; + } + + if (hasSelection && saveRange) { + rangeHelper.saveRange(); + } + + currentHtml = sourceMode ? sourceEditor.value : wysiwygBody.innerHTML; + + // Only trigger if something has actually changed. + if (currentHtml !== triggerValueChanged.lastVal) { + triggerValueChanged.lastVal = currentHtml; + + trigger(editorContainer, 'valuechanged', { + rawValue: sourceMode ? base.val() : currentHtml + }); + } + + if (hasSelection && saveRange) { + rangeHelper.removeMarkers(); + } + }; + + /** + * Should be called whenever there is a blur event + * @private + */ + valueChangedBlur = function () { + if (valueChangedKeyUpTimer) { + triggerValueChanged(); + } + }; + + /** + * Should be called whenever there is a keypress event + * @param {Event} e The keypress event + * @private + */ + valueChangedKeyUp = function (e) { + var which = e.which, + lastChar = valueChangedKeyUp.lastChar, + lastWasSpace = (lastChar === 13 || lastChar === 32), + lastWasDelete = (lastChar === 8 || lastChar === 46); + + valueChangedKeyUp.lastChar = which; + + if (isComposing) { + return; + } + + // 13 = return & 32 = space + if (which === 13 || which === 32) { + if (!lastWasSpace) { + triggerValueChanged(); + } else { + valueChangedKeyUp.triggerNext = true; + } + // 8 = backspace & 46 = del + } else if (which === 8 || which === 46) { + if (!lastWasDelete) { + triggerValueChanged(); + } else { + valueChangedKeyUp.triggerNext = true; + } + } else if (valueChangedKeyUp.triggerNext) { + triggerValueChanged(); + valueChangedKeyUp.triggerNext = false; + } + + // Clear the previous timeout and set a new one. + clearTimeout(valueChangedKeyUpTimer); + + // Trigger the event 1.5s after the last keypress if space + // isn't pressed. This might need to be lowered, will need + // to look into what the slowest average Chars Per Min is. + valueChangedKeyUpTimer = setTimeout(function () { + if (!isComposing) { + triggerValueChanged(); + } + }, 1500); + }; + + handleComposition = function (e) { + isComposing = /start/i.test(e.type); + + if (!isComposing) { + triggerValueChanged(); + } + }; + + autoUpdate = function () { + base.updateOriginal(); + }; + + // run the initializer + init(); + } + + /** + * Map containing the loaded SCEditor locales + * @type {Object} + * @name locale + * @memberOf sceditor + */ + SCEditor.locale = {}; + + SCEditor.formats = {}; + SCEditor.icons = {}; + + + /** + * Static command helper class + * @class command + * @name sceditor.command + */ + SCEditor.command = + /** @lends sceditor.command */ + { + /** + * Gets a command + * + * @param {string} name + * @return {Object|null} + * @since v1.3.5 + */ + get: function (name) { + return defaultCmds[name] || null; + }, + + /** + * <p>Adds a command to the editor or updates an existing + * command if a command with the specified name already exists.</p> + * + * <p>Once a command is add it can be included in the toolbar by + * adding it's name to the toolbar option in the constructor. It + * can also be executed manually by calling + * {@link sceditor.execCommand}</p> + * + * @example + * SCEditor.command.set("hello", + * { + * exec: function () { + * alert("Hello World!"); + * } + * }); + * + * @param {string} name + * @param {Object} cmd + * @return {this|false} Returns false if name or cmd is false + * @since v1.3.5 + */ + set: function (name, cmd) { + if (!name || !cmd) { + return false; + } + + // merge any existing command properties + cmd = extend(defaultCmds[name] || {}, cmd); + + cmd.remove = function () { + SCEditor.command.remove(name); + }; + + defaultCmds[name] = cmd; + return this; + }, + + /** + * Removes a command + * + * @param {string} name + * @return {this} + * @since v1.3.5 + */ + remove: function (name) { + if (defaultCmds[name]) { + delete defaultCmds[name]; + } + + return this; + } + }; + + /** + * SCEditor + * http://www.sceditor.com/ + * + * Copyright (C) 2017, Sam Clarke (samclarke.com) + * + * SCEditor is licensed under the MIT license: + * http://www.opensource.org/licenses/mit-license.php + * + * @fileoverview SCEditor - A lightweight WYSIWYG BBCode and HTML editor + * @author Sam Clarke + */ + + + window.sceditor = { + command: SCEditor.command, + commands: defaultCmds, + defaultOptions: defaultOptions, + + ios: ios, + isWysiwygSupported: isWysiwygSupported, + + regexEscape: regex, + escapeEntities: entities, + escapeUriScheme: uriScheme, + + dom: { + css: css, + attr: attr, + removeAttr: removeAttr, + is: is, + closest: closest, + width: width, + height: height, + traverse: traverse, + rTraverse: rTraverse, + parseHTML: parseHTML, + hasStyling: hasStyling, + convertElement: convertElement, + blockLevelList: blockLevelList, + canHaveChildren: canHaveChildren, + isInline: isInline, + copyCSS: copyCSS, + fixNesting: fixNesting, + findCommonAncestor: findCommonAncestor, + getSibling: getSibling, + removeWhiteSpace: removeWhiteSpace, + extractContents: extractContents, + getOffset: getOffset, + getStyle: getStyle, + hasStyle: hasStyle + }, + locale: SCEditor.locale, + icons: SCEditor.icons, + utils: { + each: each, + isEmptyObject: isEmptyObject, + extend: extend + }, + plugins: PluginManager.plugins, + formats: SCEditor.formats, + create: function (textarea, options) { + options = options || {}; + + // Don't allow the editor to be initialised + // on it's own source editor + if (parent(textarea, '.sceditor-container')) { + return; + } + + if (options.runWithoutWysiwygSupport || isWysiwygSupported) { + /*eslint no-new: off*/ + (new SCEditor(textarea, options)); + } + }, + instance: function (textarea) { + return textarea._sceditor; + } + }; + +}());