vendor/assets/javascripts/medium-editor.js in scrivito-medium-editor-0.0.2 vs vendor/assets/javascripts/medium-editor.js in scrivito-medium-editor-0.0.3

- old
+ new

@@ -1,2180 +1,6215 @@ -function MediumEditor(elements, options) { - 'use strict'; - return this.init(elements, options); -} +/*global self, document, DOMException */ -if (typeof module === 'object') { - module.exports = MediumEditor; -// AMD support -} else if (typeof define === 'function' && define.amd) { - define(function () { - 'use strict'; - return MediumEditor; - }); -} +/*! @source http://purl.eligrey.com/github/classList.js/blob/master/classList.js */ -(function (window, document) { - 'use strict'; +// Full polyfill for browsers with no classList support +if (!("classList" in document.createElement("_"))) { + (function (view) { - function extend(b, a) { - var prop; - if (b === undefined) { - return a; + "use strict"; + + if (!('Element' in view)) return; + + var + classListProp = "classList" + , protoProp = "prototype" + , elemCtrProto = view.Element[protoProp] + , objCtr = Object + , strTrim = String[protoProp].trim || function () { + return this.replace(/^\s+|\s+$/g, ""); + } + , arrIndexOf = Array[protoProp].indexOf || function (item) { + var + i = 0 + , len = this.length + ; + for (; i < len; i++) { + if (i in this && this[i] === item) { + return i; } - for (prop in a) { - if (a.hasOwnProperty(prop) && b.hasOwnProperty(prop) === false) { - b[prop] = a[prop]; - } - } - return b; + } + return -1; } + // Vendors: please allow content code to instantiate DOMExceptions + , DOMEx = function (type, message) { + this.name = type; + this.code = DOMException[type]; + this.message = message; + } + , checkTokenAndGetIndex = function (classList, token) { + if (token === "") { + throw new DOMEx( + "SYNTAX_ERR" + , "An invalid or illegal string was specified" + ); + } + if (/\s/.test(token)) { + throw new DOMEx( + "INVALID_CHARACTER_ERR" + , "String contains an invalid character" + ); + } + return arrIndexOf.call(classList, token); + } + , ClassList = function (elem) { + var + trimmedClasses = strTrim.call(elem.getAttribute("class") || "") + , classes = trimmedClasses ? trimmedClasses.split(/\s+/) : [] + , i = 0 + , len = classes.length + ; + for (; i < len; i++) { + this.push(classes[i]); + } + this._updateClassName = function () { + elem.setAttribute("class", this.toString()); + }; + } + , classListProto = ClassList[protoProp] = [] + , classListGetter = function () { + return new ClassList(this); + } + ; + // Most DOMException implementations don't allow calling DOMException's toString() + // on non-DOMExceptions. Error's toString() is sufficient here. + DOMEx[protoProp] = Error[protoProp]; + classListProto.item = function (i) { + return this[i] || null; + }; + classListProto.contains = function (token) { + token += ""; + return checkTokenAndGetIndex(this, token) !== -1; + }; + classListProto.add = function () { + var + tokens = arguments + , i = 0 + , l = tokens.length + , token + , updated = false + ; + do { + token = tokens[i] + ""; + if (checkTokenAndGetIndex(this, token) === -1) { + this.push(token); + updated = true; + } + } + while (++i < l); - // https://github.com/jashkenas/underscore - var now = Date.now || function () { - return new Date().getTime(); - }; + if (updated) { + this._updateClassName(); + } + }; + classListProto.remove = function () { + var + tokens = arguments + , i = 0 + , l = tokens.length + , token + , updated = false + , index + ; + do { + token = tokens[i] + ""; + index = checkTokenAndGetIndex(this, token); + while (index !== -1) { + this.splice(index, 1); + updated = true; + index = checkTokenAndGetIndex(this, token); + } + } + while (++i < l); - // https://github.com/jashkenas/underscore - function throttle(func, wait) { - var THROTTLE_INTERVAL = 50, - context, - args, - result, - timeout = null, - previous = 0, - later; + if (updated) { + this._updateClassName(); + } + }; + classListProto.toggle = function (token, force) { + token += ""; - if (!wait && wait !== 0) { - wait = THROTTLE_INTERVAL; - } + var + result = this.contains(token) + , method = result ? + force !== true && "remove" + : + force !== false && "add" + ; - later = function () { - previous = now(); - timeout = null; - result = func.apply(context, args); - if (!timeout) { - context = args = null; - } - }; + if (method) { + this[method](token); + } - return function () { - var currNow = now(), - remaining = wait - (currNow - previous); - context = this; - args = arguments; - if (remaining <= 0 || remaining > wait) { - clearTimeout(timeout); - timeout = null; - previous = currNow; - result = func.apply(context, args); - if (!timeout) { - context = args = null; - } - } else if (!timeout) { - timeout = setTimeout(later, remaining); - } - return result; - }; + if (force === true || force === false) { + return force; + } else { + return !result; } + }; + classListProto.toString = function () { + return this.join(" "); + }; - function isDescendant(parent, child) { - var node = child.parentNode; - while (node !== null) { - if (node === parent) { - return true; - } - node = node.parentNode; - } - return false; + if (objCtr.defineProperty) { + var classListPropDesc = { + get: classListGetter + , enumerable: true + , configurable: true + }; + try { + objCtr.defineProperty(elemCtrProto, classListProp, classListPropDesc); + } catch (ex) { // IE 8 doesn't support enumerable:true + if (ex.number === -0x7FF5EC54) { + classListPropDesc.enumerable = false; + objCtr.defineProperty(elemCtrProto, classListProp, classListPropDesc); + } } + } else if (objCtr[protoProp].__defineGetter__) { + elemCtrProto.__defineGetter__(classListProp, classListGetter); + } - // Find the next node in the DOM tree that represents any text that is being - // displayed directly next to the targetNode (passed as an argument) - // Text that appears directly next to the current node can be: - // - A sibling text node - // - A descendant of a sibling element - // - A sibling text node of an ancestor - // - A descendant of a sibling element of an ancestor - function findAdjacentTextNodeWithContent(rootNode, targetNode, ownerDocument) { - var pastTarget = false, - nextNode, - nodeIterator = ownerDocument.createNodeIterator(rootNode, NodeFilter.SHOW_TEXT, null, false); + }(self)); +} - // Use a native NodeIterator to iterate over all the text nodes that are descendants - // of the rootNode. Once past the targetNode, choose the first non-empty text node - nextNode = nodeIterator.nextNode(); - while (nextNode) { - if (nextNode === targetNode) { - pastTarget = true; - } else if (pastTarget) { - if (nextNode.nodeType === 3 && nextNode.nodeValue && nextNode.nodeValue.trim().length > 0) { - break; - } - } - nextNode = nodeIterator.nextNode(); - } +/* Blob.js + * A Blob implementation. + * 2014-07-24 + * + * By Eli Grey, http://eligrey.com + * By Devin Samarin, https://github.com/dsamarin + * License: X11/MIT + * See https://github.com/eligrey/Blob.js/blob/master/LICENSE.md + */ - return nextNode; - } +/*global self, unescape */ +/*jslint bitwise: true, regexp: true, confusion: true, es5: true, vars: true, white: true, + plusplus: true */ - // http://stackoverflow.com/questions/5605401/insert-link-in-contenteditable-element - // by Tim Down - function saveSelection() { - var i, - len, - ranges, - sel = this.options.contentWindow.getSelection(); - if (sel.getRangeAt && sel.rangeCount) { - ranges = []; - for (i = 0, len = sel.rangeCount; i < len; i += 1) { - ranges.push(sel.getRangeAt(i)); - } - return ranges; +/*! @source http://purl.eligrey.com/github/Blob.js/blob/master/Blob.js */ + +(function (view) { + "use strict"; + + view.URL = view.URL || view.webkitURL; + + if (view.Blob && view.URL) { + try { + new Blob; + return; + } catch (e) {} + } + + // Internally we use a BlobBuilder implementation to base Blob off of + // in order to support older browsers that only have BlobBuilder + var BlobBuilder = view.BlobBuilder || view.WebKitBlobBuilder || view.MozBlobBuilder || (function(view) { + var + get_class = function(object) { + return Object.prototype.toString.call(object).match(/^\[object\s(.*)\]$/)[1]; + } + , FakeBlobBuilder = function BlobBuilder() { + this.data = []; + } + , FakeBlob = function Blob(data, type, encoding) { + this.data = data; + this.size = data.length; + this.type = type; + this.encoding = encoding; + } + , FBB_proto = FakeBlobBuilder.prototype + , FB_proto = FakeBlob.prototype + , FileReaderSync = view.FileReaderSync + , FileException = function(type) { + this.code = this[this.name = type]; + } + , file_ex_codes = ( + "NOT_FOUND_ERR SECURITY_ERR ABORT_ERR NOT_READABLE_ERR ENCODING_ERR " + + "NO_MODIFICATION_ALLOWED_ERR INVALID_STATE_ERR SYNTAX_ERR" + ).split(" ") + , file_ex_code = file_ex_codes.length + , real_URL = view.URL || view.webkitURL || view + , real_create_object_URL = real_URL.createObjectURL + , real_revoke_object_URL = real_URL.revokeObjectURL + , URL = real_URL + , btoa = view.btoa + , atob = view.atob + + , ArrayBuffer = view.ArrayBuffer + , Uint8Array = view.Uint8Array + + , origin = /^[\w-]+:\/*\[?[\w\.:-]+\]?(?::[0-9]+)?/ + ; + FakeBlob.fake = FB_proto.fake = true; + while (file_ex_code--) { + FileException.prototype[file_ex_codes[file_ex_code]] = file_ex_code + 1; + } + // Polyfill URL + if (!real_URL.createObjectURL) { + URL = view.URL = function(uri) { + var + uri_info = document.createElementNS("http://www.w3.org/1999/xhtml", "a") + , uri_origin + ; + uri_info.href = uri; + if (!("origin" in uri_info)) { + if (uri_info.protocol.toLowerCase() === "data:") { + uri_info.origin = null; + } else { + uri_origin = uri.match(origin); + uri_info.origin = uri_origin && uri_origin[1]; + } } - return null; + return uri_info; + }; } + URL.createObjectURL = function(blob) { + var + type = blob.type + , data_URI_header + ; + if (type === null) { + type = "application/octet-stream"; + } + if (blob instanceof FakeBlob) { + data_URI_header = "data:" + type; + if (blob.encoding === "base64") { + return data_URI_header + ";base64," + blob.data; + } else if (blob.encoding === "URI") { + return data_URI_header + "," + decodeURIComponent(blob.data); + } if (btoa) { + return data_URI_header + ";base64," + btoa(blob.data); + } else { + return data_URI_header + "," + encodeURIComponent(blob.data); + } + } else if (real_create_object_URL) { + return real_create_object_URL.call(real_URL, blob); + } + }; + URL.revokeObjectURL = function(object_URL) { + if (object_URL.substring(0, 5) !== "data:" && real_revoke_object_URL) { + real_revoke_object_URL.call(real_URL, object_URL); + } + }; + FBB_proto.append = function(data/*, endings*/) { + var bb = this.data; + // decode data to a binary string + if (Uint8Array && (data instanceof ArrayBuffer || data instanceof Uint8Array)) { + var + str = "" + , buf = new Uint8Array(data) + , i = 0 + , buf_len = buf.length + ; + for (; i < buf_len; i++) { + str += String.fromCharCode(buf[i]); + } + bb.push(str); + } else if (get_class(data) === "Blob" || get_class(data) === "File") { + if (FileReaderSync) { + var fr = new FileReaderSync; + bb.push(fr.readAsBinaryString(data)); + } else { + // async FileReader won't work as BlobBuilder is sync + throw new FileException("NOT_READABLE_ERR"); + } + } else if (data instanceof FakeBlob) { + if (data.encoding === "base64" && atob) { + bb.push(atob(data.data)); + } else if (data.encoding === "URI") { + bb.push(decodeURIComponent(data.data)); + } else if (data.encoding === "raw") { + bb.push(data.data); + } + } else { + if (typeof data !== "string") { + data += ""; // convert unsupported types to strings + } + // decode UTF-16 to binary string + bb.push(unescape(encodeURIComponent(data))); + } + }; + FBB_proto.getBlob = function(type) { + if (!arguments.length) { + type = null; + } + return new FakeBlob(this.data.join(""), type, "raw"); + }; + FBB_proto.toString = function() { + return "[object BlobBuilder]"; + }; + FB_proto.slice = function(start, end, type) { + var args = arguments.length; + if (args < 3) { + type = null; + } + return new FakeBlob( + this.data.slice(start, args > 1 ? end : this.data.length) + , type + , this.encoding + ); + }; + FB_proto.toString = function() { + return "[object Blob]"; + }; + FB_proto.close = function() { + this.size = 0; + delete this.data; + }; + return FakeBlobBuilder; + }(view)); - function restoreSelection(savedSel) { - var i, - len, - sel = this.options.contentWindow.getSelection(); - if (savedSel) { - sel.removeAllRanges(); - for (i = 0, len = savedSel.length; i < len; i += 1) { - sel.addRange(savedSel[i]); - } + view.Blob = function(blobParts, options) { + var type = options ? (options.type || "") : ""; + var builder = new BlobBuilder(); + if (blobParts) { + for (var i = 0, len = blobParts.length; i < len; i++) { + if (Uint8Array && blobParts[i] instanceof Uint8Array) { + builder.append(blobParts[i].buffer); } + else { + builder.append(blobParts[i]); + } + } } + var blob = builder.getBlob(type); + if (!blob.slice && blob.webkitSlice) { + blob.slice = blob.webkitSlice; + } + return blob; + }; - // http://stackoverflow.com/questions/1197401/how-can-i-get-the-element-the-caret-is-in-with-javascript-when-using-contentedi - // by You - function getSelectionStart() { - var node = this.options.ownerDocument.getSelection().anchorNode, - startNode = (node && node.nodeType === 3 ? node.parentNode : node); - return startNode; + var getPrototypeOf = Object.getPrototypeOf || function(object) { + return object.__proto__; + }; + view.Blob.prototype = getPrototypeOf(new view.Blob()); +}(typeof self !== "undefined" && self || typeof window !== "undefined" && window || this.content || this)); + +(function (root, factory) { + 'use strict'; + if (typeof module === 'object') { + module.exports = factory; + } else if (typeof define === 'function' && define.amd) { + define(function () { + return factory; + }); + } else { + root.MediumEditor = factory; } +}(this, function () { - // http://stackoverflow.com/questions/4176923/html-of-selected-text - // by Tim Down - function getSelectionHtml() { - var i, - html = '', - sel, - len, - container; - if (this.options.contentWindow.getSelection !== undefined) { - sel = this.options.contentWindow.getSelection(); - if (sel.rangeCount) { - container = this.options.ownerDocument.createElement('div'); - for (i = 0, len = sel.rangeCount; i < len; i += 1) { - container.appendChild(sel.getRangeAt(i).cloneContents()); + 'use strict'; + +var Util; + +(function (window) { + 'use strict'; + + function copyInto(overwrite, dest) { + var prop, + sources = Array.prototype.slice.call(arguments, 2); + dest = dest || {}; + for (var i = 0; i < sources.length; i++) { + var source = sources[i]; + if (source) { + for (prop in source) { + if (source.hasOwnProperty(prop) && + typeof source[prop] !== 'undefined' && + (overwrite || dest.hasOwnProperty(prop) === false)) { + dest[prop] = source[prop]; + } } - html = container.innerHTML; } - } else if (this.options.ownerDocument.selection !== undefined) { - if (this.options.ownerDocument.selection.type === 'Text') { - html = this.options.ownerDocument.selection.createRange().htmlText; - } } - return html; + return dest; } - /** - * Find the caret position within an element irrespective of any inline tags it may contain. - * - * @param {DOMElement} An element containing the cursor to find offsets relative to. - * @param {Range} A Range representing cursor position. Will window.getSelection if none is passed. - * @return {Object} 'left' and 'right' attributes contain offsets from begining and end of Element - */ - function getCaretOffsets(element, range) { - var preCaretRange, postCaretRange; + Util = { - if (!range) { - range = window.getSelection().getRangeAt(0); - } + // http://stackoverflow.com/questions/17907445/how-to-detect-ie11#comment30165888_17907562 + // by rg89 + isIE: ((navigator.appName === 'Microsoft Internet Explorer') || ((navigator.appName === 'Netscape') && (new RegExp('Trident/.*rv:([0-9]{1,}[.0-9]{0,})').exec(navigator.userAgent) !== null))), - preCaretRange = range.cloneRange(); - postCaretRange = range.cloneRange(); + // http://stackoverflow.com/a/11752084/569101 + isMac: (window.navigator.platform.toUpperCase().indexOf('MAC') >= 0), - preCaretRange.selectNodeContents(element); - preCaretRange.setEnd(range.endContainer, range.endOffset); + // https://github.com/jashkenas/underscore + keyCode: { + BACKSPACE: 8, + TAB: 9, + ENTER: 13, + ESCAPE: 27, + SPACE: 32, + DELETE: 46, + K: 75 // K keycode, and not k + }, - postCaretRange.selectNodeContents(element); - postCaretRange.setStart(range.endContainer, range.endOffset); + /** + * Returns true if it's metaKey on Mac, or ctrlKey on non-Mac. + * See #591 + */ + isMetaCtrlKey: function (event) { + if ((this.isMac && event.metaKey) || (!this.isMac && event.ctrlKey)) { + return true; + } - return { - left: preCaretRange.toString().length, - right: postCaretRange.toString().length - }; - } + return false; + }, + /** + * Returns true if the key associated to the event is inside keys array + * + * @see : https://github.com/jquery/jquery/blob/0705be475092aede1eddae01319ec931fb9c65fc/src/event.js#L473-L484 + * @see : http://stackoverflow.com/q/4471582/569101 + */ + isKey: function (event, keys) { + var keyCode = this.getKeyCode(event); - // https://github.com/jashkenas/underscore - function isElement(obj) { - return !!(obj && obj.nodeType === 1); - } + // it's not an array let's just compare strings! + if (false === Array.isArray(keys)) { + return keyCode === keys; + } - // http://stackoverflow.com/questions/6690752/insert-html-at-caret-in-a-contenteditable-div - function insertHTMLCommand(doc, html) { - var selection, range, el, fragment, node, lastNode; + if (-1 === keys.indexOf(keyCode)) { + return false; + } - if (doc.queryCommandSupported('insertHTML')) { - return doc.execCommand('insertHTML', false, html); - } + return true; + }, - selection = window.getSelection(); - if (selection.getRangeAt && selection.rangeCount) { - range = selection.getRangeAt(0); - range.deleteContents(); + getKeyCode: function (event) { + var keyCode = event.which; - el = doc.createElement("div"); - el.innerHTML = html; - fragment = doc.createDocumentFragment(); - while (el.firstChild) { - node = el.firstChild; - lastNode = fragment.appendChild(node); + // getting the key code from event + if (null === keyCode) { + keyCode = event.charCode !== null ? event.charCode : event.keyCode; } - range.insertNode(fragment); - // Preserve the selection: - if (lastNode) { - range = range.cloneRange(); - range.setStartAfter(lastNode); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - } - } - } + return keyCode; + }, - MediumEditor.prototype = { - defaults: { - allowMultiParagraphSelection: true, - anchorInputPlaceholder: 'Paste or type a link', - anchorInputCheckboxLabel: 'Open in new window', - anchorPreviewHideDelay: 500, - buttons: ['bold', 'italic', 'underline', 'anchor', 'header1', 'header2', 'quote'], - buttonLabels: false, - checkLinkFormat: false, - cleanPastedHTML: false, - delay: 0, - diffLeft: 0, - diffTop: -10, - disableReturn: false, - disableDoubleReturn: false, - disableToolbar: false, - disableEditing: false, - disableAnchorForm: false, - disablePlaceholders: false, - elementsContainer: false, - standardizeSelectionStart: false, - contentWindow: window, - ownerDocument: document, - firstHeader: 'h3', - forcePlainText: true, - placeholder: 'Type your text', - secondHeader: 'h4', - targetBlank: false, - anchorTarget: false, - anchorButton: false, - anchorButtonClass: 'btn', - extensions: {}, - activeButtonClass: 'medium-editor-button-active', - firstButtonClass: 'medium-editor-button-first', - lastButtonClass: 'medium-editor-button-last' + blockContainerElementNames: ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre'], + emptyElementNames: ['br', 'col', 'colgroup', 'hr', 'img', 'input', 'source', 'wbr'], + + extend: function extend(/* dest, source1, source2, ...*/) { + var args = [true].concat(Array.prototype.slice.call(arguments)); + return copyInto.apply(this, args); }, - // http://stackoverflow.com/questions/17907445/how-to-detect-ie11#comment30165888_17907562 - // by rg89 - isIE: ((navigator.appName === 'Microsoft Internet Explorer') || ((navigator.appName === 'Netscape') && (new RegExp('Trident/.*rv:([0-9]{1,}[.0-9]{0,})').exec(navigator.userAgent) !== null))), + defaults: function defaults(/*dest, source1, source2, ...*/) { + var args = [false].concat(Array.prototype.slice.call(arguments)); + return copyInto.apply(this, args); + }, - init: function (elements, options) { - var uniqueId = 1; + /* + * Create a link around the provided text nodes which must be adjacent to each other and all be + * descendants of the same closest block container. If the preconditions are not met, unexpected + * behavior will result. + */ + createLink: function (document, textNodes, href, target) { + var anchor = document.createElement('a'); + Util.moveTextRangeIntoElement(textNodes[0], textNodes[textNodes.length - 1], anchor); + anchor.setAttribute('href', href); + if (target) { + anchor.setAttribute('target', target); + } + return anchor; + }, - this.options = extend(options, this.defaults); - this.setElementSelection(elements); - if (this.elements.length === 0) { - return; + /* + * Given the provided match in the format {start: 1, end: 2} where start and end are indices into the + * textContent of the provided element argument, modify the DOM inside element to ensure that the text + * identified by the provided match can be returned as text nodes that contain exactly that text, without + * any additional text at the beginning or end of the returned array of adjacent text nodes. + * + * The only DOM manipulation performed by this function is splitting the text nodes, non-text nodes are + * not affected in any way. + */ + findOrCreateMatchingTextNodes: function (document, element, match) { + var treeWalker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false), + matchedNodes = [], + currentTextIndex = 0, + startReached = false, + currentNode = null, + newNode = null; + + while ((currentNode = treeWalker.nextNode()) !== null) { + if (!startReached && match.start < (currentTextIndex + currentNode.nodeValue.length)) { + startReached = true; + newNode = Util.splitStartNodeIfNeeded(currentNode, match.start, currentTextIndex); + } + if (startReached) { + Util.splitEndNodeIfNeeded(currentNode, newNode, match.end, currentTextIndex); + } + if (startReached && currentTextIndex === match.end) { + break; // Found the node(s) corresponding to the link. Break out and move on to the next. + } else if (startReached && currentTextIndex > (match.end + 1)) { + throw new Error('PerformLinking overshot the target!'); // should never happen... + } + + if (startReached) { + matchedNodes.push(newNode || currentNode); + } + + currentTextIndex += currentNode.nodeValue.length; + if (newNode !== null) { + currentTextIndex += newNode.nodeValue.length; + // Skip the newNode as we'll already have pushed it to the matches + treeWalker.nextNode(); + } + newNode = null; } - this.parentElements = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre']; - if (!this.options.elementsContainer) { - this.options.elementsContainer = this.options.ownerDocument.body; + return matchedNodes; + }, + + /* + * Given the provided text node and text coordinates, split the text node if needed to make it align + * precisely with the coordinates. + * + * This function is intended to be called from Util.findOrCreateMatchingTextNodes. + */ + splitStartNodeIfNeeded: function (currentNode, matchStartIndex, currentTextIndex) { + if (matchStartIndex !== currentTextIndex) { + return currentNode.splitText(matchStartIndex - currentTextIndex); } + return null; + }, - while (this.options.elementsContainer.querySelector('#medium-editor-toolbar-' + uniqueId)) { - uniqueId = uniqueId + 1; + /* + * Given the provided text node and text coordinates, split the text node if needed to make it align + * precisely with the coordinates. The newNode argument should from the result of Util.splitStartNodeIfNeeded, + * if that function has been called on the same currentNode. + * + * This function is intended to be called from Util.findOrCreateMatchingTextNodes. + */ + splitEndNodeIfNeeded: function (currentNode, newNode, matchEndIndex, currentTextIndex) { + var textIndexOfEndOfFarthestNode, + endSplitPoint; + textIndexOfEndOfFarthestNode = currentTextIndex + (newNode || currentNode).nodeValue.length + + (newNode ? currentNode.nodeValue.length : 0) - + 1; + endSplitPoint = (newNode || currentNode).nodeValue.length - + (textIndexOfEndOfFarthestNode + 1 - matchEndIndex); + if (textIndexOfEndOfFarthestNode >= matchEndIndex && + currentTextIndex !== textIndexOfEndOfFarthestNode && + endSplitPoint !== 0) { + (newNode || currentNode).splitText(endSplitPoint); } + }, - this.id = uniqueId; + // Find the next node in the DOM tree that represents any text that is being + // displayed directly next to the targetNode (passed as an argument) + // Text that appears directly next to the current node can be: + // - A sibling text node + // - A descendant of a sibling element + // - A sibling text node of an ancestor + // - A descendant of a sibling element of an ancestor + findAdjacentTextNodeWithContent: function findAdjacentTextNodeWithContent(rootNode, targetNode, ownerDocument) { + var pastTarget = false, + nextNode, + nodeIterator = ownerDocument.createNodeIterator(rootNode, NodeFilter.SHOW_TEXT, null, false); - return this.setup(); + // Use a native NodeIterator to iterate over all the text nodes that are descendants + // of the rootNode. Once past the targetNode, choose the first non-empty text node + nextNode = nodeIterator.nextNode(); + while (nextNode) { + if (nextNode === targetNode) { + pastTarget = true; + } else if (pastTarget) { + if (nextNode.nodeType === 3 && nextNode.nodeValue && nextNode.nodeValue.trim().length > 0) { + break; + } + } + nextNode = nodeIterator.nextNode(); + } + + return nextNode; }, - setup: function () { - this.events = []; - this.isActive = true; - this.initThrottledMethods() - .initElements() - .bindSelect() - .bindPaste() - .setPlaceholders() - .bindElementActions() - .bindWindowActions(); - //.passInstance(); + isDescendant: function isDescendant(parent, child, checkEquality) { + if (!parent || !child) { + return false; + } + if (checkEquality && parent === child) { + return true; + } + var node = child.parentNode; + while (node !== null) { + if (node === parent) { + return true; + } + node = node.parentNode; + } + return false; }, - on: function (target, event, listener, useCapture) { - target.addEventListener(event, listener, useCapture); - this.events.push([target, event, listener, useCapture]); + // https://github.com/jashkenas/underscore + isElement: function isElement(obj) { + return !!(obj && obj.nodeType === 1); }, - off: function (target, event, listener, useCapture) { - var index = this.indexOfListener(target, event, listener, useCapture), - e; - if (index !== -1) { - e = this.events.splice(index, 1)[0]; - e[0].removeEventListener(e[1], e[2], e[3]); + // https://github.com/jashkenas/underscore + throttle: function (func, wait) { + var THROTTLE_INTERVAL = 50, + context, + args, + result, + timeout = null, + previous = 0, + later = function () { + previous = Date.now(); + timeout = null; + result = func.apply(context, args); + if (!timeout) { + context = args = null; + } + }; + + if (!wait && wait !== 0) { + wait = THROTTLE_INTERVAL; } - }, - indexOfListener: function (target, event, listener, useCapture) { - var i, n, item; - for (i = 0, n = this.events.length; i < n; i = i + 1) { - item = this.events[i]; - if (item[0] === target && item[1] === event && item[2] === listener && item[3] === useCapture) { - return i; + return function () { + var now = Date.now(), + remaining = wait - (now - previous); + + context = this; + args = arguments; + if (remaining <= 0 || remaining > wait) { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + previous = now; + result = func.apply(context, args); + if (!timeout) { + context = args = null; + } + } else if (!timeout) { + timeout = setTimeout(later, remaining); } - } - return -1; + return result; + }; }, - delay: function (fn) { - var self = this; - setTimeout(function () { - if (self.isActive) { - fn(); + traverseUp: function (current, testElementFunction) { + if (!current) { + return false; + } + + do { + if (current.nodeType === 1) { + if (testElementFunction(current)) { + return current; + } + // do not traverse upwards past the nearest containing editor + if (Util.isMediumEditorElement(current)) { + return false; + } } - }, this.options.delay); + + current = current.parentNode; + } while (current); + + return false; }, - removeAllEvents: function () { - var e = this.events.pop(); - while (e) { - e[0].removeEventListener(e[1], e[2], e[3]); - e = this.events.pop(); + htmlEntities: function (str) { + // converts special characters (like <) into their escaped/encoded values (like &lt;). + // This allows you to show to display the string without the browser reading it as HTML. + return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); + }, + + // http://stackoverflow.com/questions/6690752/insert-html-at-caret-in-a-contenteditable-div + insertHTMLCommand: function (doc, html) { + var selection, range, el, fragment, node, lastNode, toReplace; + + if (doc.queryCommandSupported('insertHTML')) { + try { + return doc.execCommand('insertHTML', false, html); + } catch (ignore) {} } + + selection = doc.defaultView.getSelection(); + if (selection.getRangeAt && selection.rangeCount) { + range = selection.getRangeAt(0); + toReplace = range.commonAncestorContainer; + // Ensure range covers maximum amount of nodes as possible + // By moving up the DOM and selecting ancestors whose only child is the range + if ((toReplace.nodeType === 3 && toReplace.nodeValue === range.toString()) || + (toReplace.nodeType !== 3 && toReplace.innerHTML === range.toString())) { + while (toReplace.parentNode && + toReplace.parentNode.childNodes.length === 1 && + !Util.isMediumEditorElement(toReplace.parentNode)) { + toReplace = toReplace.parentNode; + } + range.selectNode(toReplace); + } + range.deleteContents(); + + el = doc.createElement('div'); + el.innerHTML = html; + fragment = doc.createDocumentFragment(); + while (el.firstChild) { + node = el.firstChild; + lastNode = fragment.appendChild(node); + } + range.insertNode(fragment); + + // Preserve the selection: + if (lastNode) { + range = range.cloneRange(); + range.setStartAfter(lastNode); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } + } }, - initThrottledMethods: function () { - var self = this; + execFormatBlock: function (doc, tagName) { + // Get the top level block element that contains the selection + var blockContainer = Util.getTopBlockContainer(Selection.getSelectionStart(doc)); - // handleResize is throttled because: - // - It will be called when the browser is resizing, which can fire many times very quickly - // - For some event (like resize) a slight lag in UI responsiveness is OK and provides performance benefits - this.handleResize = throttle(function () { - if (self.isActive) { - self.positionToolbarIfShown(); + // Special handling for blockquote + if (tagName === 'blockquote') { + if (blockContainer) { + var childNodes = Array.prototype.slice.call(blockContainer.childNodes); + // Check if the blockquote has a block element as a child (nested blocks) + if (childNodes.some(function (childNode) { + return Util.isBlockContainer(childNode); + })) { + // FF handles blockquote differently on formatBlock + // allowing nesting, we need to use outdent + // https://developer.mozilla.org/en-US/docs/Rich-Text_Editing_in_Mozilla + return doc.execCommand('outdent', false, null); + } } - }); - // handleBlur is throttled because: - // - This method could be called many times due to the type of event handlers that are calling it - // - We want a slight delay so that other events in the stack can run, some of which may - // prevent the toolbar from being hidden (via this.keepToolbarAlive). - this.handleBlur = throttle(function () { - if (self.isActive && !self.keepToolbarAlive) { - self.hideToolbarActions(); + // When IE blockquote needs to be called as indent + // http://stackoverflow.com/questions/1816223/rich-text-editor-with-blockquote-function/1821777#1821777 + if (this.isIE) { + return doc.execCommand('indent', false, tagName); } - }); + } - return this; + // If the blockContainer is already the element type being passed in + // treat it as 'undo' formatting and just convert it to a <p> + if (blockContainer && tagName === blockContainer.nodeName.toLowerCase()) { + tagName = 'p'; + } + + // When IE we need to add <> to heading elements + // http://stackoverflow.com/questions/10741831/execcommand-formatblock-headings-in-ie + if (this.isIE) { + tagName = '<' + tagName + '>'; + } + return doc.execCommand('formatBlock', false, tagName); }, - initElements: function () { - var i, - addToolbar = false; - for (i = 0; i < this.elements.length; i += 1) { - if (!this.options.disableEditing && !this.elements[i].getAttribute('data-disable-editing')) { - this.elements[i].setAttribute('contentEditable', true); + /** + * Set target to blank on the given el element + * + * TODO: not sure if this should be here + * + * When creating a link (using core -> createLink) the selection returned by Firefox will be the parent of the created link + * instead of the created link itself (as it is for Chrome for example), so we retrieve all "a" children to grab the good one by + * using `anchorUrl` to ensure that we are adding target="_blank" on the good one. + * This isn't a bulletproof solution anyway .. + */ + setTargetBlank: function (el, anchorUrl) { + var i, url = anchorUrl || false; + if (el.nodeName.toLowerCase() === 'a') { + el.target = '_blank'; + } else { + el = el.getElementsByTagName('a'); + + for (i = 0; i < el.length; i += 1) { + if (false === url || url === el[i].attributes.href.value) { + el[i].target = '_blank'; + } } - if (!this.elements[i].getAttribute('data-placeholder')) { - this.elements[i].setAttribute('data-placeholder', this.options.placeholder); + } + }, + + addClassToAnchors: function (el, buttonClass) { + var classes = buttonClass.split(' '), + i, + j; + if (el.nodeName.toLowerCase() === 'a') { + for (j = 0; j < classes.length; j += 1) { + el.classList.add(classes[j]); } - this.elements[i].setAttribute('data-medium-element', true); - this.elements[i].setAttribute('role', 'textbox'); - this.elements[i].setAttribute('aria-multiline', true); - this.bindParagraphCreation(i); - if (!this.options.disableToolbar && !this.elements[i].getAttribute('data-disable-toolbar')) { - addToolbar = true; + } else { + el = el.getElementsByTagName('a'); + for (i = 0; i < el.length; i += 1) { + for (j = 0; j < classes.length; j += 1) { + el[i].classList.add(classes[j]); + } } } - // Init toolbar - if (addToolbar) { - this.passInstance() - .callExtensions('init') - .initToolbar() - .bindButtons() - .bindAnchorForm() - .bindAnchorPreview(); - } - return this; }, - setElementSelection: function (selector) { - if (!selector) { - selector = []; + isListItem: function (node) { + if (!node) { + return false; } - // If string, use as query selector - if (typeof selector === 'string') { - selector = this.options.ownerDocument.querySelectorAll(selector); + if (node.nodeName.toLowerCase() === 'li') { + return true; } - // If element, put into array - if (isElement(selector)) { - selector = [selector]; + + var parentNode = node.parentNode, + tagName = parentNode.nodeName.toLowerCase(); + while (!this.isBlockContainer(parentNode) && tagName !== 'div') { + if (tagName === 'li') { + return true; + } + parentNode = parentNode.parentNode; + if (parentNode) { + tagName = parentNode.nodeName.toLowerCase(); + } else { + return false; + } } - // Convert NodeList (or other array like object) into an array - this.elements = Array.prototype.slice.apply(selector); + return false; }, - bindBlur: function () { - var self = this, - blurFunction = function (e) { - var isDescendantOfEditorElements = false, - i; - for (i = 0; i < self.elements.length; i += 1) { - if (isDescendant(self.elements[i], e.target)) { - isDescendantOfEditorElements = true; - break; - } + cleanListDOM: function (ownerDocument, element) { + if (element.nodeName.toLowerCase() !== 'li') { + return; + } + + var list = element.parentElement; + + if (list.parentElement.nodeName.toLowerCase() === 'p') { // yes we need to clean up + this.unwrap(list.parentElement, ownerDocument); + + // move cursor at the end of the text inside the list + // for some unknown reason, the cursor is moved to end of the "visual" line + Selection.moveCursor(ownerDocument, element.firstChild, element.firstChild.textContent.length); + } + }, + + /* splitDOMTree + * + * Given a root element some descendant element, split the root element + * into its own element containing the descendant element and all elements + * on the left or right side of the descendant ('right' is default) + * + * example: + * + * <div> + * / | \ + * <span> <span> <span> + * / \ / \ / \ + * 1 2 3 4 5 6 + * + * If I wanted to split this tree given the <div> as the root and "4" as the leaf + * the result would be (the prime ' marks indicates nodes that are created as clones): + * + * SPLITTING OFF 'RIGHT' TREE SPLITTING OFF 'LEFT' TREE + * + * <div> <div>' <div>' <div> + * / \ / \ / \ | + * <span> <span> <span>' <span> <span> <span> <span> + * / \ | | / \ /\ /\ /\ + * 1 2 3 4 5 6 1 2 3 4 5 6 + * + * The above example represents splitting off the 'right' or 'left' part of a tree, where + * the <div>' would be returned as an element not appended to the DOM, and the <div> + * would remain in place where it was + * + */ + splitOffDOMTree: function (rootNode, leafNode, splitLeft) { + var splitOnNode = leafNode, + createdNode = null, + splitRight = !splitLeft; + + // loop until we hit the root + while (splitOnNode !== rootNode) { + var currParent = splitOnNode.parentNode, + newParent = currParent.cloneNode(false), + targetNode = (splitRight ? splitOnNode : currParent.firstChild), + appendLast; + + // Create a new parent element which is a clone of the current parent + if (createdNode) { + if (splitRight) { + // If we're splitting right, add previous created element before siblings + newParent.appendChild(createdNode); + } else { + // If we're splitting left, add previous created element last + appendLast = createdNode; } - // If it's not part of the editor, or the toolbar - if (e.target !== self.toolbar - && self.elements.indexOf(e.target) === -1 - && !isDescendantOfEditorElements - && !isDescendant(self.toolbar, e.target) - && !isDescendant(self.anchorPreview, e.target)) { + } + createdNode = newParent; - // Activate the placeholder - if (!self.options.disablePlaceholders) { - self.placeholderWrapper(e, self.elements[0]); + while (targetNode) { + var sibling = targetNode.nextSibling; + // Special handling for the 'splitNode' + if (targetNode === splitOnNode) { + if (!targetNode.hasChildNodes()) { + targetNode.parentNode.removeChild(targetNode); + } else { + // For the node we're splitting on, if it has children, we need to clone it + // and not just move it + targetNode = targetNode.cloneNode(false); } + // If the resulting split node has content, add it + if (targetNode.textContent) { + createdNode.appendChild(targetNode); + } - // Hide the toolbar after a small delay so we can prevent this on toolbar click - self.handleBlur(); + targetNode = (splitRight ? sibling : null); + } else { + // For general case, just remove the element and only + // add it to the split tree if it contains something + targetNode.parentNode.removeChild(targetNode); + if (targetNode.hasChildNodes() || targetNode.textContent) { + createdNode.appendChild(targetNode); + } + + targetNode = sibling; } - }; + } - // Hide the toolbar when focusing outside of the editor. - this.on(this.options.ownerDocument.body, 'click', blurFunction, true); - this.on(this.options.ownerDocument.body, 'focus', blurFunction, true); + // If we had an element we wanted to append at the end, do that now + if (appendLast) { + createdNode.appendChild(appendLast); + } - return this; + splitOnNode = currParent; + } + + return createdNode; }, - bindClick: function (i) { - var self = this; + moveTextRangeIntoElement: function (startNode, endNode, newElement) { + if (!startNode || !endNode) { + return false; + } - this.on(this.elements[i], 'click', function () { - if (!self.options.disablePlaceholders) { - // Remove placeholder - this.classList.remove('medium-editor-placeholder'); + var rootNode = this.findCommonRoot(startNode, endNode); + if (!rootNode) { + return false; + } + + if (endNode === startNode) { + var temp = startNode.parentNode, + sibling = startNode.nextSibling; + temp.removeChild(startNode); + newElement.appendChild(startNode); + if (sibling) { + temp.insertBefore(newElement, sibling); + } else { + temp.appendChild(newElement); } + return newElement.hasChildNodes(); + } - if (self.options.staticToolbar) { - self.setToolbarPosition(); + // create rootChildren array which includes all the children + // we care about + var rootChildren = [], + firstChild, + lastChild, + nextNode; + for (var i = 0; i < rootNode.childNodes.length; i++) { + nextNode = rootNode.childNodes[i]; + if (!firstChild) { + if (this.isDescendant(nextNode, startNode, true)) { + firstChild = nextNode; + } + } else { + if (this.isDescendant(nextNode, endNode, true)) { + lastChild = nextNode; + break; + } else { + rootChildren.push(nextNode); + } } + } + + var afterLast = lastChild.nextSibling, + fragment = rootNode.ownerDocument.createDocumentFragment(); + + // build up fragment on startNode side of tree + if (firstChild === startNode) { + firstChild.parentNode.removeChild(firstChild); + fragment.appendChild(firstChild); + } else { + fragment.appendChild(this.splitOffDOMTree(firstChild, startNode)); + } + + // add any elements between firstChild & lastChild + rootChildren.forEach(function (element) { + element.parentNode.removeChild(element); + fragment.appendChild(element); }); - return this; + // build up fragment on endNode side of the tree + if (lastChild === endNode) { + lastChild.parentNode.removeChild(lastChild); + fragment.appendChild(lastChild); + } else { + fragment.appendChild(this.splitOffDOMTree(lastChild, endNode, true)); + } + + // Add fragment into passed in element + newElement.appendChild(fragment); + + if (lastChild.parentNode === rootNode) { + // If last child is in the root, insert newElement in front of it + rootNode.insertBefore(newElement, lastChild); + } else if (afterLast) { + // If last child was removed, but it had a sibling, insert in front of it + rootNode.insertBefore(newElement, afterLast); + } else { + // lastChild was removed and was the last actual element just append + rootNode.appendChild(newElement); + } + + return newElement.hasChildNodes(); }, - /** - * This handles blur and keypress events on elements - * Including Placeholders, and tooldbar hiding on blur - */ - bindElementActions: function () { - var i; + /* based on http://stackoverflow.com/a/6183069 */ + depthOfNode: function (inNode) { + var theDepth = 0, + node = inNode; + while (node.parentNode !== null) { + node = node.parentNode; + theDepth++; + } + return theDepth; + }, - for (i = 0; i < this.elements.length; i += 1) { + findCommonRoot: function (inNode1, inNode2) { + var depth1 = this.depthOfNode(inNode1), + depth2 = this.depthOfNode(inNode2), + node1 = inNode1, + node2 = inNode2; - if (!this.options.disablePlaceholders) { - // Active all of the placeholders - this.activatePlaceholder(this.elements[i]); + while (depth1 !== depth2) { + if (depth1 > depth2) { + node1 = node1.parentNode; + depth1 -= 1; + } else { + node2 = node2.parentNode; + depth2 -= 1; } + } - // Bind the return and tab keypress events - this.bindReturn(i) - .bindKeydown(i) - .bindBlur() - .bindClick(i); + while (node1 !== node2) { + node1 = node1.parentNode; + node2 = node2.parentNode; } - return this; + return node1; }, + /* END - based on http://stackoverflow.com/a/6183069 */ - // Two functions to handle placeholders - activatePlaceholder: function (el) { - if (!(el.querySelector('img')) && - !(el.querySelector('blockquote')) && - el.textContent.replace(/^\s+|\s+$/g, '') === '') { + isElementAtBeginningOfBlock: function (node) { + var textVal, + sibling; + while (!this.isBlockContainer(node) && !this.isMediumEditorElement(node)) { + sibling = node; + while (sibling = sibling.previousSibling) { + textVal = sibling.nodeType === 3 ? sibling.nodeValue : sibling.textContent; + if (textVal.length > 0) { + return false; + } + } + node = node.parentNode; + } + return true; + }, - el.classList.add('medium-editor-placeholder'); + isMediumEditorElement: function (element) { + return element && element.getAttribute && !!element.getAttribute('data-medium-editor-element'); + }, + + getContainerEditorElement: function (element) { + return Util.traverseUp(element, function (node) { + return Util.isMediumEditorElement(node); + }); + }, + + isBlockContainer: function (element) { + return element && element.nodeType !== 3 && this.blockContainerElementNames.indexOf(element.nodeName.toLowerCase()) !== -1; + }, + + getClosestBlockContainer: function (node) { + return Util.traverseUp(node, function (node) { + return Util.isBlockContainer(node); + }); + }, + + getTopBlockContainer: function (element) { + var topBlock = element; + this.traverseUp(element, function (el) { + if (Util.isBlockContainer(el)) { + topBlock = el; + } + return false; + }); + return topBlock; + }, + + getFirstSelectableLeafNode: function (element) { + while (element && element.firstChild) { + element = element.firstChild; } + + // We don't want to set the selection to an element that can't have children, this messes up Gecko. + element = this.traverseUp(element, function (el) { + return Util.emptyElementNames.indexOf(el.nodeName.toLowerCase()) === -1; + }); + // Selecting at the beginning of a table doesn't work in PhantomJS. + if (element.nodeName.toLowerCase() === 'table') { + var firstCell = element.querySelector('th, td'); + if (firstCell) { + element = firstCell; + } + } + return element; }, - placeholderWrapper: function (evt, el) { - el = el || evt.target; - el.classList.remove('medium-editor-placeholder'); - if (evt.type !== 'keypress') { - this.activatePlaceholder(el); + + getFirstTextNode: function (element) { + if (element.nodeType === 3) { + return element; } + + for (var i = 0; i < element.childNodes.length; i++) { + var textNode = this.getFirstTextNode(element.childNodes[i]); + if (textNode !== null) { + return textNode; + } + } + return null; }, - serialize: function () { - var i, - elementid, - content = {}; - for (i = 0; i < this.elements.length; i += 1) { - elementid = (this.elements[i].id !== '') ? this.elements[i].id : 'element-' + i; - content[elementid] = { - value: this.elements[i].innerHTML.trim() - }; + ensureUrlHasProtocol: function (url) { + if (url.indexOf('://') === -1) { + return 'http://' + url; } - return content; + return url; }, - /** - * Helper function to call a method with a number of parameters on all registered extensions. - * The function assures that the function exists before calling. - * - * @param {string} funcName name of the function to call - * @param [args] arguments passed into funcName - */ - callExtensions: function (funcName) { - if (arguments.length < 1) { - return; + warn: function () { + if (window.console !== undefined && typeof window.console.warn === 'function') { + window.console.warn.apply(window.console, arguments); } + }, - var args = Array.prototype.slice.call(arguments, 1), - ext, - name; + deprecated: function (oldName, newName, version) { + // simple deprecation warning mechanism. + var m = oldName + ' is deprecated, please use ' + newName + ' instead.'; + if (version) { + m += ' Will be removed in ' + version; + } + Util.warn(m); + }, - for (name in this.options.extensions) { - if (this.options.extensions.hasOwnProperty(name)) { - ext = this.options.extensions[name]; - if (ext[funcName] !== undefined) { - ext[funcName].apply(ext, args); - } + deprecatedMethod: function (oldName, newName, args, version) { + // run the replacement and warn when someone calls a deprecated method + Util.deprecated(oldName, newName, version); + if (typeof this[newName] === 'function') { + this[newName].apply(this, args); + } + }, + + cleanupAttrs: function (el, attrs) { + attrs.forEach(function (attr) { + el.removeAttribute(attr); + }); + }, + + cleanupTags: function (el, tags) { + tags.forEach(function (tag) { + if (el.nodeName.toLowerCase() === tag) { + el.parentNode.removeChild(el); } + }, this); + }, + + // get the closest parent + getClosestTag: function (el, tag) { + return this.traverseUp(el, function (element) { + return element.nodeName.toLowerCase() === tag.toLowerCase(); + }); + }, + + unwrap: function (el, doc) { + var fragment = doc.createDocumentFragment(), + nodes = Array.prototype.slice.call(el.childNodes); + + // cast nodeList to array since appending child + // to a different node will alter length of el.childNodes + for (var i = 0; i < nodes.length; i++) { + fragment.appendChild(nodes[i]); } - return this; + + if (fragment.childNodes.length) { + el.parentNode.replaceChild(fragment, el); + } else { + el.parentNode.removeChild(el); + } + } + }; +}(window)); + +var buttonDefaults; +(function () { + 'use strict'; + + buttonDefaults = { + 'bold': { + name: 'bold', + action: 'bold', + aria: 'bold', + tagNames: ['b', 'strong'], + style: { + prop: 'font-weight', + value: '700|bold' + }, + useQueryState: true, + contentDefault: '<b>B</b>', + contentFA: '<i class="fa fa-bold"></i>' }, + 'italic': { + name: 'italic', + action: 'italic', + aria: 'italic', + tagNames: ['i', 'em'], + style: { + prop: 'font-style', + value: 'italic' + }, + useQueryState: true, + contentDefault: '<b><i>I</i></b>', + contentFA: '<i class="fa fa-italic"></i>' + }, + 'underline': { + name: 'underline', + action: 'underline', + aria: 'underline', + tagNames: ['u'], + style: { + prop: 'text-decoration', + value: 'underline' + }, + useQueryState: true, + contentDefault: '<b><u>U</u></b>', + contentFA: '<i class="fa fa-underline"></i>' + }, + 'strikethrough': { + name: 'strikethrough', + action: 'strikethrough', + aria: 'strike through', + tagNames: ['strike'], + style: { + prop: 'text-decoration', + value: 'line-through' + }, + useQueryState: true, + contentDefault: '<s>A</s>', + contentFA: '<i class="fa fa-strikethrough"></i>' + }, + 'superscript': { + name: 'superscript', + action: 'superscript', + aria: 'superscript', + tagNames: ['sup'], + /* firefox doesn't behave the way we want it to, so we CAN'T use queryCommandState for superscript + https://github.com/guardian/scribe/blob/master/BROWSERINCONSISTENCIES.md#documentquerycommandstate */ + // useQueryState: true + contentDefault: '<b>x<sup>1</sup></b>', + contentFA: '<i class="fa fa-superscript"></i>' + }, + 'subscript': { + name: 'subscript', + action: 'subscript', + aria: 'subscript', + tagNames: ['sub'], + /* firefox doesn't behave the way we want it to, so we CAN'T use queryCommandState for subscript + https://github.com/guardian/scribe/blob/master/BROWSERINCONSISTENCIES.md#documentquerycommandstate */ + // useQueryState: true + contentDefault: '<b>x<sub>1</sub></b>', + contentFA: '<i class="fa fa-subscript"></i>' + }, + 'image': { + name: 'image', + action: 'image', + aria: 'image', + tagNames: ['img'], + contentDefault: '<b>image</b>', + contentFA: '<i class="fa fa-picture-o"></i>' + }, + 'orderedlist': { + name: 'orderedlist', + action: 'insertorderedlist', + aria: 'ordered list', + tagNames: ['ol'], + useQueryState: true, + contentDefault: '<b>1.</b>', + contentFA: '<i class="fa fa-list-ol"></i>' + }, + 'unorderedlist': { + name: 'unorderedlist', + action: 'insertunorderedlist', + aria: 'unordered list', + tagNames: ['ul'], + useQueryState: true, + contentDefault: '<b>&bull;</b>', + contentFA: '<i class="fa fa-list-ul"></i>' + }, + 'indent': { + name: 'indent', + action: 'indent', + aria: 'indent', + tagNames: [], + contentDefault: '<b>&rarr;</b>', + contentFA: '<i class="fa fa-indent"></i>' + }, + 'outdent': { + name: 'outdent', + action: 'outdent', + aria: 'outdent', + tagNames: [], + contentDefault: '<b>&larr;</b>', + contentFA: '<i class="fa fa-outdent"></i>' + }, + 'justifyCenter': { + name: 'justifyCenter', + action: 'justifyCenter', + aria: 'center justify', + tagNames: [], + style: { + prop: 'text-align', + value: 'center' + }, + contentDefault: '<b>C</b>', + contentFA: '<i class="fa fa-align-center"></i>' + }, + 'justifyFull': { + name: 'justifyFull', + action: 'justifyFull', + aria: 'full justify', + tagNames: [], + style: { + prop: 'text-align', + value: 'justify' + }, + contentDefault: '<b>J</b>', + contentFA: '<i class="fa fa-align-justify"></i>' + }, + 'justifyLeft': { + name: 'justifyLeft', + action: 'justifyLeft', + aria: 'left justify', + tagNames: [], + style: { + prop: 'text-align', + value: 'left' + }, + contentDefault: '<b>L</b>', + contentFA: '<i class="fa fa-align-left"></i>' + }, + 'justifyRight': { + name: 'justifyRight', + action: 'justifyRight', + aria: 'right justify', + tagNames: [], + style: { + prop: 'text-align', + value: 'right' + }, + contentDefault: '<b>R</b>', + contentFA: '<i class="fa fa-align-right"></i>' + }, + // Known inline elements that are not removed, or not removed consistantly across browsers: + // <span>, <label>, <br> + 'removeFormat': { + name: 'removeFormat', + aria: 'remove formatting', + action: 'removeFormat', + contentDefault: '<b>X</b>', + contentFA: '<i class="fa fa-eraser"></i>' + }, - /** - * Pass current Medium Editor instance to all extensions - * if extension constructor has 'parent' attribute set to 'true' + /***** Buttons for appending block elements (append-<element> action) *****/ + + 'quote': { + name: 'quote', + action: 'append-blockquote', + aria: 'blockquote', + tagNames: ['blockquote'], + contentDefault: '<b>&ldquo;</b>', + contentFA: '<i class="fa fa-quote-right"></i>' + }, + 'pre': { + name: 'pre', + action: 'append-pre', + aria: 'preformatted text', + tagNames: ['pre'], + contentDefault: '<b>0101</b>', + contentFA: '<i class="fa fa-code fa-lg"></i>' + }, + 'h1': { + name: 'h1', + action: 'append-h1', + aria: 'header type one', + tagNames: ['h1'], + contentDefault: '<b>H1</b>', + contentFA: '<i class="fa fa-header"><sup>1</sup>' + }, + 'h2': { + name: 'h2', + action: 'append-h2', + aria: 'header type two', + tagNames: ['h2'], + contentDefault: '<b>H2</b>', + contentFA: '<i class="fa fa-header"><sup>2</sup>' + }, + 'h3': { + name: 'h3', + action: 'append-h3', + aria: 'header type three', + tagNames: ['h3'], + contentDefault: '<b>H3</b>', + contentFA: '<i class="fa fa-header"><sup>3</sup>' + }, + 'h4': { + name: 'h4', + action: 'append-h4', + aria: 'header type four', + tagNames: ['h4'], + contentDefault: '<b>H4</b>', + contentFA: '<i class="fa fa-header"><sup>4</sup>' + }, + 'h5': { + name: 'h5', + action: 'append-h5', + aria: 'header type five', + tagNames: ['h5'], + contentDefault: '<b>H5</b>', + contentFA: '<i class="fa fa-header"><sup>5</sup>' + }, + 'h6': { + name: 'h6', + action: 'append-h6', + aria: 'header type six', + tagNames: ['h6'], + contentDefault: '<b>H6</b>', + contentFA: '<i class="fa fa-header"><sup>6</sup>' + } + }; + +})(); +var editorDefaults; +(function () { + // summary: The default options hash used by the Editor + + editorDefaults = { + activeButtonClass: 'medium-editor-button-active', + buttonLabels: false, + delay: 0, + disableReturn: false, + disableDoubleReturn: false, + disableEditing: false, + autoLink: false, + elementsContainer: false, + contentWindow: window, + ownerDocument: document, + targetBlank: false, + extensions: {}, + spellcheck: true + }; +})(); + +var Extension; +(function () { + 'use strict'; + + /* global Util */ + + Extension = function (options) { + Util.extend(this, options); + }; + + Extension.extend = function (protoProps) { + // magic extender thinger. mostly borrowed from backbone/goog.inherits + // place this function on some thing you want extend-able. + // + // example: + // + // function Thing(args){ + // this.options = args; + // } + // + // Thing.prototype = { foo: "bar" }; + // Thing.extend = extenderify; + // + // var ThingTwo = Thing.extend({ foo: "baz" }); + // + // var thingOne = new Thing(); // foo === "bar" + // var thingTwo = new ThingTwo(); // foo === "baz" + // + // which seems like some simply shallow copy nonsense + // at first, but a lot more is going on there. + // + // passing a `constructor` to the extend props + // will cause the instance to instantiate through that + // instead of the parent's constructor. + + var parent = this, + child; + + // The constructor function for the new subclass is either defined by you + // (the "constructor" property in your `extend` definition), or defaulted + // by us to simply call the parent's constructor. + + if (protoProps && protoProps.hasOwnProperty('constructor')) { + child = protoProps.constructor; + } else { + child = function () { + return parent.apply(this, arguments); + }; + } + + // das statics (.extend comes over, so your subclass can have subclasses too) + Util.extend(child, parent); + + // Set the prototype chain to inherit from `parent`, without calling + // `parent`'s constructor function. + var Surrogate = function () { + this.constructor = child; + }; + Surrogate.prototype = parent.prototype; + child.prototype = new Surrogate(); + + if (protoProps) { + Util.extend(child.prototype, protoProps); + } + + // todo: $super? + + return child; + }; + + Extension.prototype = { + /* init: [function] * + * Called by MediumEditor during initialization. + * The .base property will already have been set to + * current instance of MediumEditor when this is called. + * All helper methods will exist as well */ - passInstance: function () { - var self = this, - ext, - name; + init: function () {}, - for (name in self.options.extensions) { - if (self.options.extensions.hasOwnProperty(name)) { - ext = self.options.extensions[name]; + /* base: [MediumEditor instance] + * + * If not overriden, this will be set to the current instance + * of MediumEditor, before the init method is called + */ + base: undefined, - if (ext.parent) { - ext.base = self; + /* name: [string] + * + * 'name' of the extension, used for retrieving the extension. + * If not set, MediumEditor will set this to be the key + * used when passing the extension into MediumEditor via the + * 'extensions' option + */ + name: undefined, + + /* checkState: [function (node)] + * + * If implemented, this function will be called one or more times + * the state of the editor & toolbar are updated. + * When the state is updated, the editor does the following: + * + * 1) Find the parent node containing the current selection + * 2) Call checkState on the extension, passing the node as an argument + * 3) Get the parent node of the previous node + * 4) Repeat steps #2 and #3 until we move outside the parent contenteditable + */ + checkState: undefined, + + /* destroy: [function ()] + * + * This method should remove any created html, custom event handlers + * or any other cleanup tasks that should be performed. + * If implemented, this function will be called when MediumEditor's + * destroy method has been called. + */ + destroy: undefined, + + /* As alternatives to checkState, these functions provide a more structured + * path to updating the state of an extension (usually a button) whenever + * the state of the editor & toolbar are updated. + */ + + /* queryCommandState: [function ()] + * + * If implemented, this function will be called once on each extension + * when the state of the editor/toolbar is being updated. + * + * If this function returns a non-null value, the extension will + * be ignored as the code climbs the dom tree. + * + * If this function returns true, and the setActive() function is defined + * setActive() will be called + */ + queryCommandState: undefined, + + /* isActive: [function ()] + * + * If implemented, this function will be called when MediumEditor + * has determined that this extension is 'active' for the current selection. + * This may be called when the editor & toolbar are being updated, + * but only if queryCommandState() or isAlreadyApplied() functions + * are implemented, and when called, return true. + */ + isActive: undefined, + + /* isAlreadyApplied: [function (node)] + * + * If implemented, this function is similar to checkState() in + * that it will be called repeatedly as MediumEditor moves up + * the DOM to update the editor & toolbar after a state change. + * + * NOTE: This function will NOT be called if checkState() has + * been implemented. This function will NOT be called if + * queryCommandState() is implemented and returns a non-null + * value when called + */ + isAlreadyApplied: undefined, + + /* setActive: [function ()] + * + * If implemented, this function is called when MediumEditor knows + * that this extension is currently enabled. Currently, this + * function is called when updating the editor & toolbar, and + * only if queryCommandState() or isAlreadyApplied(node) return + * true when called + */ + setActive: undefined, + + /* setInactive: [function ()] + * + * If implemented, this function is called when MediumEditor knows + * that this extension is currently disabled. Curently, this + * is called at the beginning of each state change for + * the editor & toolbar. After calling this, MediumEditor + * will attempt to update the extension, either via checkState() + * or the combination of queryCommandState(), isAlreadyApplied(node), + * isActive(), and setActive() + */ + setInactive: undefined, + + /************************ Helpers ************************ + * The following are helpers that are either set by MediumEditor + * during initialization, or are helper methods which either + * route calls to the MediumEditor instance or provide common + * functionality for all extensions + *********************************************************/ + + /* window: [Window] + * + * If not overriden, this will be set to the window object + * to be used by MediumEditor and its extensions. This is + * passed via the 'contentWindow' option to MediumEditor + * and is the global 'window' object by default + */ + 'window': undefined, + + /* document: [Document] + * + * If not overriden, this will be set to the document object + * to be used by MediumEditor and its extensions. This is + * passed via the 'ownerDocument' optin to MediumEditor + * and is the global 'document' object by default + */ + 'document': undefined, + + /* getEditorElements: [function ()] + * + * Helper function which returns an array containing + * all the contenteditable elements for this instance + * of MediumEditor + */ + getEditorElements: function () { + return this.base.elements; + }, + + /* getEditorId: [function ()] + * + * Helper function which returns a unique identifier + * for this instance of MediumEditor + */ + getEditorId: function () { + return this.base.id; + }, + + /* getEditorOptions: [function (option)] + * + * Helper function which returns the value of an option + * used to initialize this instance of MediumEditor + */ + getEditorOption: function (option) { + return this.base.options[option]; + } + }; + + /* List of method names to add to the prototype of Extension + * Each of these methods will be defined as helpers that + * just call directly into the MediumEditor instance. + * + * example for 'on' method: + * Extension.prototype.on = function () { + * return this.base.on.apply(this.base, arguments); + * } + */ + [ + // general helpers + 'execAction', + + // event handling + 'on', + 'off', + 'subscribe', + 'trigger' + + ].forEach(function (helper) { + Extension.prototype[helper] = function () { + return this.base[helper].apply(this.base, arguments); + }; + }); +})(); + +var Selection; + +(function () { + 'use strict'; + + function filterOnlyParentElements(node) { + if (Util.isBlockContainer(node)) { + return NodeFilter.FILTER_ACCEPT; + } else { + return NodeFilter.FILTER_SKIP; + } + } + + Selection = { + findMatchingSelectionParent: function (testElementFunction, contentWindow) { + var selection = contentWindow.getSelection(), + range, + current; + + if (selection.rangeCount === 0) { + return false; + } + + range = selection.getRangeAt(0); + current = range.commonAncestorContainer; + + return Util.traverseUp(current, testElementFunction); + }, + + getSelectionElement: function (contentWindow) { + return this.findMatchingSelectionParent(function (el) { + return Util.isMediumEditorElement(el); + }, contentWindow); + }, + + // http://stackoverflow.com/questions/17678843/cant-restore-selection-after-html-modify-even-if-its-the-same-html + // Tim Down + exportSelection: function (root, doc) { + if (!root) { + return null; + } + + var selectionState = null, + selection = doc.getSelection(); + + if (selection.rangeCount > 0) { + var range = selection.getRangeAt(0), + preSelectionRange = range.cloneRange(), + start; + + preSelectionRange.selectNodeContents(root); + preSelectionRange.setEnd(range.startContainer, range.startOffset); + start = preSelectionRange.toString().length; + + selectionState = { + start: start, + end: start + range.toString().length + }; + // If start = 0 there may still be an empty paragraph before it, but we don't care. + if (start !== 0) { + var emptyBlocksIndex = this.getIndexRelativeToAdjacentEmptyBlocks(doc, root, range.startContainer, range.startOffset); + if (emptyBlocksIndex !== 0) { + selectionState.emptyBlocksIndex = emptyBlocksIndex; } } } - return self; + return selectionState; }, - bindParagraphCreation: function (index) { - var self = this; - this.on(this.elements[index], 'keypress', function (e) { - var node, - tagName; - if (e.which === 32) { - node = getSelectionStart.call(self); - tagName = node.tagName.toLowerCase(); - if (tagName === 'a') { - self.options.ownerDocument.execCommand('unlink', false, null); + // http://stackoverflow.com/questions/17678843/cant-restore-selection-after-html-modify-even-if-its-the-same-html + // Tim Down + // + // {object} selectionState - the selection to import + // {DOMElement} root - the root element the selection is being restored inside of + // {Document} doc - the document to use for managing selection + // {boolean} [favorLaterSelectionAnchor] - defaults to false. If true, import the cursor immediately + // subsequent to an anchor tag if it would otherwise be placed right at the trailing edge inside the + // anchor. This cursor positioning, even though visually equivalent to the user, can affect behavior + // in MS IE. + importSelection: function (selectionState, root, doc, favorLaterSelectionAnchor) { + if (!selectionState || !root) { + return; + } + + var range = doc.createRange(); + range.setStart(root, 0); + range.collapse(true); + + var node = root, + nodeStack = [], + charIndex = 0, + foundStart = false, + stop = false, + nextCharIndex; + + while (!stop && node) { + if (node.nodeType === 3) { + nextCharIndex = charIndex + node.length; + if (!foundStart && selectionState.start >= charIndex && selectionState.start <= nextCharIndex) { + range.setStart(node, selectionState.start - charIndex); + foundStart = true; } + if (foundStart && selectionState.end >= charIndex && selectionState.end <= nextCharIndex) { + range.setEnd(node, selectionState.end - charIndex); + stop = true; + } + charIndex = nextCharIndex; + } else { + var i = node.childNodes.length - 1; + while (i >= 0) { + nodeStack.push(node.childNodes[i]); + i -= 1; + } } - }); + if (!stop) { + node = nodeStack.pop(); + } + } - this.on(this.elements[index], 'keyup', function (e) { - var node = getSelectionStart.call(self), - tagName, - editorElement; - - if (node && node.getAttribute('data-medium-element') && node.children.length === 0 && !(self.options.disableReturn || node.getAttribute('data-disable-return'))) { - self.options.ownerDocument.execCommand('formatBlock', false, 'p'); + if (selectionState.emptyBlocksIndex && selectionState.end === nextCharIndex) { + var targetNode = Util.getTopBlockContainer(range.startContainer), + index = 0; + // Skip over empty blocks until we hit the block we want the selection to be in + while (index < selectionState.emptyBlocksIndex && targetNode.nextSibling) { + targetNode = targetNode.nextSibling; + index++; + // If we find a non-empty block, ignore the emptyBlocksIndex and just put selection here + if (targetNode.textContent.length > 0) { + break; + } } - if (e.which === 13) { - node = getSelectionStart.call(self); - tagName = node.tagName.toLowerCase(); - editorElement = self.getSelectionElement(); - if (!(self.options.disableReturn || editorElement.getAttribute('data-disable-return')) && - tagName !== 'li' && !self.isListItemChild(node)) { - if (!e.shiftKey) { + // We're selecting a high-level block node, so make sure the cursor gets moved into the deepest + // element at the beginning of the block + range.setStart(Util.getFirstSelectableLeafNode(targetNode), 0); + range.collapse(true); + } - // paragraph creation should not be forced within a header tag - if (!/h\d/.test(tagName)) { - self.options.ownerDocument.execCommand('formatBlock', false, 'p'); - } + // If the selection is right at the ending edge of a link, put it outside the anchor tag instead of inside. + if (favorLaterSelectionAnchor) { + range = Selection.importSelectionMoveCursorPastAnchor(selectionState, range); + } + + var sel = doc.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + }, + + // Utility method called from importSelection only + importSelectionMoveCursorPastAnchor: function (selectionState, range) { + var nodeInsideAnchorTagFunction = function (node) { + return node.nodeName.toLowerCase() === 'a'; + }; + if (selectionState.start === selectionState.end && + range.startContainer.nodeType === 3 && + range.startOffset === range.startContainer.nodeValue.length && + Util.traverseUp(range.startContainer, nodeInsideAnchorTagFunction)) { + var prevNode = range.startContainer, + currentNode = range.startContainer.parentNode; + while (currentNode !== null && currentNode.nodeName.toLowerCase() !== 'a') { + if (currentNode.childNodes[currentNode.childNodes.length - 1] !== prevNode) { + currentNode = null; + } else { + prevNode = currentNode; + currentNode = currentNode.parentNode; + } + } + if (currentNode !== null && currentNode.nodeName.toLowerCase() === 'a') { + var currentNodeIndex = null; + for (var i = 0; currentNodeIndex === null && i < currentNode.parentNode.childNodes.length; i++) { + if (currentNode.parentNode.childNodes[i] === currentNode) { + currentNodeIndex = i; } - if (tagName === 'a') { - self.options.ownerDocument.execCommand('unlink', false, null); - } } + range.setStart(currentNode.parentNode, currentNodeIndex + 1); + range.collapse(true); } - }); - return this; + } + return range; }, - isListItemChild: function (node) { - var parentNode = node.parentNode, - tagName = parentNode.tagName.toLowerCase(); - while (this.parentElements.indexOf(tagName) === -1 && tagName !== 'div') { - if (tagName === 'li') { - return true; + // Returns 0 unless the cursor is within or preceded by empty paragraphs/blocks, + // in which case it returns the count of such preceding paragraphs, including + // the empty paragraph in which the cursor itself may be embedded. + getIndexRelativeToAdjacentEmptyBlocks: function (doc, root, cursorContainer, cursorOffset) { + // If there is text in front of the cursor, that means there isn't only empty blocks before it + if (cursorContainer.nodeType === 3 && cursorOffset > 0) { + return 0; + } + + // Check if the block that contains the cursor has any other text in front of the cursor + var node = cursorContainer; + if (node.nodeType !== 3) { + //node = cursorContainer.childNodes.length === cursorOffset ? null : cursorContainer.childNodes[cursorOffset]; + node = cursorContainer.childNodes[cursorOffset]; + } + if (node && !Util.isElementAtBeginningOfBlock(node)) { + return 0; + } + + // Walk over block elements, counting number of empty blocks between last piece of text + // and the block the cursor is in + var treeWalker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, filterOnlyParentElements, false), + emptyBlocksCount = 0; + while (treeWalker.nextNode()) { + var blockIsEmpty = treeWalker.currentNode.textContent === ''; + if (blockIsEmpty || emptyBlocksCount > 0) { + emptyBlocksCount += 1; } - parentNode = parentNode.parentNode; - if (parentNode && parentNode.tagName) { - tagName = parentNode.tagName.toLowerCase(); - } else { - return false; + if (Util.isDescendant(treeWalker.currentNode, cursorContainer, true)) { + return emptyBlocksCount; } + if (!blockIsEmpty) { + emptyBlocksCount = 0; + } } - return false; + + return emptyBlocksCount; }, - bindReturn: function (index) { - var self = this; - this.on(this.elements[index], 'keypress', function (e) { - if (e.which === 13) { - if (self.options.disableReturn || this.getAttribute('data-disable-return')) { - e.preventDefault(); - } else if (self.options.disableDoubleReturn || this.getAttribute('data-disable-double-return')) { - var node = getSelectionStart.call(self); - if (node && node.textContent === '\n') { - e.preventDefault(); - } + selectionInContentEditableFalse: function (contentWindow) { + // determine if the current selection is exclusively inside + // a contenteditable="false", though treat the case of an + // explicit contenteditable="true" inside a "false" as false. + var sawtrue, + sawfalse = this.findMatchingSelectionParent(function (el) { + var ce = el && el.getAttribute('contenteditable'); + if (ce === 'true') { + sawtrue = true; } + return el.nodeName !== '#text' && ce === 'false'; + }, contentWindow); + + return !sawtrue && sawfalse; + }, + + // http://stackoverflow.com/questions/4176923/html-of-selected-text + // by Tim Down + getSelectionHtml: function getSelectionHtml(doc) { + var i, + html = '', + sel = doc.getSelection(), + len, + container; + if (sel.rangeCount) { + container = doc.createElement('div'); + for (i = 0, len = sel.rangeCount; i < len; i += 1) { + container.appendChild(sel.getRangeAt(i).cloneContents()); } - }); - return this; + html = container.innerHTML; + } + return html; }, - bindKeydown: function (index) { - var self = this; - this.on(this.elements[index], 'keydown', function (e) { + /** + * Find the caret position within an element irrespective of any inline tags it may contain. + * + * @param {DOMElement} An element containing the cursor to find offsets relative to. + * @param {Range} A Range representing cursor position. Will window.getSelection if none is passed. + * @return {Object} 'left' and 'right' attributes contain offsets from begining and end of Element + */ + getCaretOffsets: function getCaretOffsets(element, range) { + var preCaretRange, postCaretRange; - if (e.which === 9) { - // Override tab only for pre nodes - var tag = getSelectionStart.call(self).tagName.toLowerCase(); - if (tag === 'pre') { - e.preventDefault(); - self.options.ownerDocument.execCommand('insertHtml', null, ' '); - } + if (!range) { + range = window.getSelection().getRangeAt(0); + } - // Tab to indent list structures! - if (tag === 'li') { - e.preventDefault(); + preCaretRange = range.cloneRange(); + postCaretRange = range.cloneRange(); - // If Shift is down, outdent, otherwise indent - if (e.shiftKey) { - self.options.ownerDocument.execCommand('outdent', e); - } else { - self.options.ownerDocument.execCommand('indent', e); - } - } - } else if (e.which === 8 || e.which === 46 || e.which === 13) { + preCaretRange.selectNodeContents(element); + preCaretRange.setEnd(range.endContainer, range.endOffset); - // Bind keys which can create or destroy a block element: backspace, delete, return - self.onBlockModifier(e); + postCaretRange.selectNodeContents(element); + postCaretRange.setStart(range.endContainer, range.endOffset); - } - }); - return this; + return { + left: preCaretRange.toString().length, + right: postCaretRange.toString().length + }; }, - onBlockModifier: function (e) { + // http://stackoverflow.com/questions/15867542/range-object-get-selection-parent-node-chrome-vs-firefox + rangeSelectsSingleNode: function (range) { + var startNode = range.startContainer; + return startNode === range.endContainer && + startNode.hasChildNodes() && + range.endOffset === range.startOffset + 1; + }, - var range, sel, p, node = getSelectionStart.call(this), - tagName = node.tagName.toLowerCase(), - isEmpty = /^(\s+|<br\/?>)?$/i, - isHeader = /h\d/i; + getSelectedParentElement: function (range) { + if (!range) { + return null; + } - // backspace or return - if ((e.which === 8 || e.which === 13) - && node.previousElementSibling - // in a header - && isHeader.test(tagName) - // at the very end of the block - && getCaretOffsets(node).left === 0) { - if (e.which === 8 && isEmpty.test(node.previousElementSibling.innerHTML)) { - // backspacing the begining of a header into an empty previous element will - // change the tagName of the current node to prevent one - // instead delete previous node and cancel the event. - node.previousElementSibling.parentNode.removeChild(node.previousElementSibling); - e.preventDefault(); - } else if (e.which === 13) { - // hitting return in the begining of a header will create empty header elements before the current one - // instead, make "<p><br></p>" element, which are what happens if you hit return in an empty paragraph - p = this.options.ownerDocument.createElement('p'); - p.innerHTML = '<br>'; - node.previousElementSibling.parentNode.insertBefore(p, node); - e.preventDefault(); + // Selection encompasses a single element + if (this.rangeSelectsSingleNode(range) && range.startContainer.childNodes[range.startOffset].nodeType !== 3) { + return range.startContainer.childNodes[range.startOffset]; + } + + // Selection range starts inside a text node, so get its parent + if (range.startContainer.nodeType === 3) { + return range.startContainer.parentNode; + } + + // Selection starts inside an element + return range.startContainer; + }, + + getSelectedElements: function (doc) { + var selection = doc.getSelection(), + range, + toRet, + currNode; + + if (!selection.rangeCount || selection.isCollapsed || !selection.getRangeAt(0).commonAncestorContainer) { + return []; + } + + range = selection.getRangeAt(0); + + if (range.commonAncestorContainer.nodeType === 3) { + toRet = []; + currNode = range.commonAncestorContainer; + while (currNode.parentNode && currNode.parentNode.childNodes.length === 1) { + toRet.push(currNode.parentNode); + currNode = currNode.parentNode; } - // delete - } else if (e.which === 46 - && node.nextElementSibling - && node.previousElementSibling - // not in a header - && !isHeader.test(tagName) - // in an empty tag - && isEmpty.test(node.innerHTML) - // when the next tag *is* a header - && isHeader.test(node.nextElementSibling.tagName)) { - // hitting delete in an empty element preceding a header, ex: - // <p>[CURSOR]</p><h1>Header</h1> - // Will cause the h1 to become a paragraph. - // Instead, delete the paragraph node and move the cursor to the begining of the h1 + return toRet; + } - // remove node and move cursor to start of header - range = document.createRange(); - sel = window.getSelection(); + return [].filter.call(range.commonAncestorContainer.getElementsByTagName('*'), function (el) { + return (typeof selection.containsNode === 'function') ? selection.containsNode(el, true) : true; + }); + }, - range.setStart(node.nextElementSibling, 0); + selectNode: function (node, doc) { + var range = doc.createRange(), + sel = doc.getSelection(); + + range.selectNodeContents(node); + sel.removeAllRanges(); + sel.addRange(range); + }, + + select: function (doc, startNode, startOffset, endNode, endOffset) { + doc.getSelection().removeAllRanges(); + var range = doc.createRange(); + range.setStart(startNode, startOffset); + if (endNode) { + range.setEnd(endNode, endOffset); + } else { range.collapse(true); + } + doc.getSelection().addRange(range); + return range; + }, - sel.removeAllRanges(); - sel.addRange(range); + /** + * Move cursor to the given node with the given offset. + * + * @param {DomDocument} doc Current document + * @param {DomElement} node Element where to jump + * @param {integer} offset Where in the element should we jump, 0 by default + */ + moveCursor: function (doc, node, offset) { + this.select(doc, node, offset); + }, - node.previousElementSibling.parentNode.removeChild(node); + getSelectionRange: function (ownerDocument) { + var selection = ownerDocument.getSelection(); + if (selection.rangeCount === 0) { + return null; + } + return selection.getRangeAt(0); + }, - e.preventDefault(); + // http://stackoverflow.com/questions/1197401/how-can-i-get-the-element-the-caret-is-in-with-javascript-when-using-contentedi + // by You + getSelectionStart: function (ownerDocument) { + var node = ownerDocument.getSelection().anchorNode, + startNode = (node && node.nodeType === 3 ? node.parentNode : node); + + return startNode; + } + }; +}()); + +var Events; + +(function () { + 'use strict'; + + Events = function (instance) { + this.base = instance; + this.options = this.base.options; + this.events = []; + this.customEvents = {}; + this.listeners = {}; + }; + + Events.prototype = { + InputEventOnContenteditableSupported: !Util.isIE, + + // Helpers for event handling + + attachDOMEvent: function (target, event, listener, useCapture) { + target.addEventListener(event, listener, useCapture); + this.events.push([target, event, listener, useCapture]); + }, + + detachDOMEvent: function (target, event, listener, useCapture) { + var index = this.indexOfListener(target, event, listener, useCapture), + e; + if (index !== -1) { + e = this.events.splice(index, 1)[0]; + e[0].removeEventListener(e[1], e[2], e[3]); } + }, + indexOfListener: function (target, event, listener, useCapture) { + var i, n, item; + for (i = 0, n = this.events.length; i < n; i = i + 1) { + item = this.events[i]; + if (item[0] === target && item[1] === event && item[2] === listener && item[3] === useCapture) { + return i; + } + } + return -1; }, - buttonTemplate: function (btnType) { - var buttonLabels = this.getButtonLabels(this.options.buttonLabels), - buttonTemplates = { - 'bold': '<button class="medium-editor-action medium-editor-action-bold" data-action="bold" data-element="b" aria-label="bold">' + buttonLabels.bold + '</button>', - 'italic': '<button class="medium-editor-action medium-editor-action-italic" data-action="italic" data-element="i" aria-label="italic">' + buttonLabels.italic + '</button>', - 'underline': '<button class="medium-editor-action medium-editor-action-underline" data-action="underline" data-element="u" aria-label="underline">' + buttonLabels.underline + '</button>', - 'strikethrough': '<button class="medium-editor-action medium-editor-action-strikethrough" data-action="strikethrough" data-element="strike" aria-label="strike through">' + buttonLabels.strikethrough + '</button>', - 'superscript': '<button class="medium-editor-action medium-editor-action-superscript" data-action="superscript" data-element="sup" aria-label="superscript">' + buttonLabels.superscript + '</button>', - 'subscript': '<button class="medium-editor-action medium-editor-action-subscript" data-action="subscript" data-element="sub" aria-label="subscript">' + buttonLabels.subscript + '</button>', - 'anchor': '<button class="medium-editor-action medium-editor-action-anchor" data-action="anchor" data-element="a" aria-label="link">' + buttonLabels.anchor + '</button>', - 'image': '<button class="medium-editor-action medium-editor-action-image" data-action="image" data-element="img" aria-label="image">' + buttonLabels.image + '</button>', - 'header1': '<button class="medium-editor-action medium-editor-action-header1" data-action="append-' + this.options.firstHeader + '" data-element="' + this.options.firstHeader + '" aria-label="h1">' + buttonLabels.header1 + '</button>', - 'header2': '<button class="medium-editor-action medium-editor-action-header2" data-action="append-' + this.options.secondHeader + '" data-element="' + this.options.secondHeader + ' "aria-label="h2">' + buttonLabels.header2 + '</button>', - 'quote': '<button class="medium-editor-action medium-editor-action-quote" data-action="append-blockquote" data-element="blockquote" aria-label="blockquote">' + buttonLabels.quote + '</button>', - 'orderedlist': '<button class="medium-editor-action medium-editor-action-orderedlist" data-action="insertorderedlist" data-element="ol" aria-label="ordered list">' + buttonLabels.orderedlist + '</button>', - 'unorderedlist': '<button class="medium-editor-action medium-editor-action-unorderedlist" data-action="insertunorderedlist" data-element="ul" aria-label="unordered list">' + buttonLabels.unorderedlist + '</button>', - 'pre': '<button class="medium-editor-action medium-editor-action-pre" data-action="append-pre" data-element="pre" aria-label="preformatted text">' + buttonLabels.pre + '</button>', - 'indent': '<button class="medium-editor-action medium-editor-action-indent" data-action="indent" data-element="ul" aria-label="indent">' + buttonLabels.indent + '</button>', - 'outdent': '<button class="medium-editor-action medium-editor-action-outdent" data-action="outdent" data-element="ul" aria-label="outdent">' + buttonLabels.outdent + '</button>', - 'justifyCenter': '<button class="medium-editor-action medium-editor-action-justifyCenter" data-action="justifyCenter" data-element="" aria-label="center justify">' + buttonLabels.justifyCenter + '</button>', - 'justifyFull': '<button class="medium-editor-action medium-editor-action-justifyFull" data-action="justifyFull" data-element="" aria-label="full justify">' + buttonLabels.justifyFull + '</button>', - 'justifyLeft': '<button class="medium-editor-action medium-editor-action-justifyLeft" data-action="justifyLeft" data-element="" aria-label="left justify">' + buttonLabels.justifyLeft + '</button>', - 'justifyRight': '<button class="medium-editor-action medium-editor-action-justifyRight" data-action="justifyRight" data-element="" aria-label="right justify">' + buttonLabels.justifyRight + '</button>' - }; - return buttonTemplates[btnType] || false; + detachAllDOMEvents: function () { + var e = this.events.pop(); + while (e) { + e[0].removeEventListener(e[1], e[2], e[3]); + e = this.events.pop(); + } }, - // TODO: break method - getButtonLabels: function (buttonLabelType) { - var customButtonLabels, - attrname, - buttonLabels = { - 'bold': '<b>B</b>', - 'italic': '<b><i>I</i></b>', - 'underline': '<b><u>U</u></b>', - 'strikethrough': '<s>A</s>', - 'superscript': '<b>x<sup>1</sup></b>', - 'subscript': '<b>x<sub>1</sub></b>', - 'anchor': '<b>#</b>', - 'image': '<b>image</b>', - 'header1': '<b>H1</b>', - 'header2': '<b>H2</b>', - 'quote': '<b>&ldquo;</b>', - 'orderedlist': '<b>1.</b>', - 'unorderedlist': '<b>&bull;</b>', - 'pre': '<b>0101</b>', - 'indent': '<b>&rarr;</b>', - 'outdent': '<b>&larr;</b>', - 'justifyCenter': '<b>C</b>', - 'justifyFull': '<b>J</b>', - 'justifyLeft': '<b>L</b>', - 'justifyRight': '<b>R</b>' - }; - if (buttonLabelType === 'fontawesome') { - customButtonLabels = { - 'bold': '<i class="fa fa-bold"></i>', - 'italic': '<i class="fa fa-italic"></i>', - 'underline': '<i class="fa fa-underline"></i>', - 'strikethrough': '<i class="fa fa-strikethrough"></i>', - 'superscript': '<i class="fa fa-superscript"></i>', - 'subscript': '<i class="fa fa-subscript"></i>', - 'anchor': '<i class="fa fa-link"></i>', - 'image': '<i class="fa fa-picture-o"></i>', - 'quote': '<i class="fa fa-quote-right"></i>', - 'orderedlist': '<i class="fa fa-list-ol"></i>', - 'unorderedlist': '<i class="fa fa-list-ul"></i>', - 'pre': '<i class="fa fa-code fa-lg"></i>', - 'indent': '<i class="fa fa-indent"></i>', - 'outdent': '<i class="fa fa-outdent"></i>', - 'justifyCenter': '<i class="fa fa-align-center"></i>', - 'justifyFull': '<i class="fa fa-align-justify"></i>', - 'justifyLeft': '<i class="fa fa-align-left"></i>', - 'justifyRight': '<i class="fa fa-align-right"></i>' - }; - } else if (typeof buttonLabelType === 'object') { - customButtonLabels = buttonLabelType; + // custom events + attachCustomEvent: function (event, listener) { + this.setupListener(event); + if (!this.customEvents[event]) { + this.customEvents[event] = []; } - if (typeof customButtonLabels === 'object') { - for (attrname in customButtonLabels) { - if (customButtonLabels.hasOwnProperty(attrname)) { - buttonLabels[attrname] = customButtonLabels[attrname]; - } - } + this.customEvents[event].push(listener); + }, + + detachCustomEvent: function (event, listener) { + var index = this.indexOfCustomListener(event, listener); + if (index !== -1) { + this.customEvents[event].splice(index, 1); + // TODO: If array is empty, should detach internal listeners via destroyListener() } - return buttonLabels; }, - initToolbar: function () { - if (this.toolbar) { - return this; + indexOfCustomListener: function (event, listener) { + if (!this.customEvents[event] || !this.customEvents[event].length) { + return -1; } - this.toolbar = this.createToolbar(); - this.addExtensionForms(); - this.keepToolbarAlive = false; - this.toolbarActions = this.toolbar.querySelector('.medium-editor-toolbar-actions'); - this.anchorPreview = this.createAnchorPreview(); - if (!this.options.disableAnchorForm) { - this.anchorForm = this.toolbar.querySelector('.medium-editor-toolbar-form'); - this.anchorInput = this.anchorForm.querySelector('input.medium-editor-toolbar-input'); - this.anchorTarget = this.anchorForm.querySelector('input.medium-editor-toolbar-anchor-target'); - this.anchorButton = this.anchorForm.querySelector('input.medium-editor-toolbar-anchor-button'); + return this.customEvents[event].indexOf(listener); + }, + + detachAllCustomEvents: function () { + this.customEvents = {}; + // TODO: Should detach internal listeners here via destroyListener() + }, + + triggerCustomEvent: function (name, data, editable) { + if (this.customEvents[name]) { + this.customEvents[name].forEach(function (listener) { + listener(data, editable); + }); } - return this; }, - createToolbar: function () { - var toolbar = this.options.ownerDocument.createElement('div'); - toolbar.id = 'medium-editor-toolbar-' + this.id; - toolbar.className = 'medium-editor-toolbar'; + // Cleaning up - if (this.options.staticToolbar) { - toolbar.className += " static-toolbar"; - } else { - toolbar.className += " stalker-toolbar"; + destroy: function () { + this.detachAllDOMEvents(); + this.detachAllCustomEvents(); + this.detachExecCommand(); + + if (this.base.elements) { + this.base.elements.forEach(function (element) { + element.removeAttribute('data-medium-focused'); + }); } + }, - toolbar.appendChild(this.toolbarButtons()); - if (!this.options.disableAnchorForm) { - toolbar.appendChild(this.toolbarFormAnchor()); + // Listening to calls to document.execCommand + + // Attach a listener to be notified when document.execCommand is called + attachToExecCommand: function () { + if (this.execCommandListener) { + return; } - this.options.elementsContainer.appendChild(toolbar); - return toolbar; + + // Store an instance of the listener so: + // 1) We only attach to execCommand once + // 2) We can remove the listener later + this.execCommandListener = function (execInfo) { + this.handleDocumentExecCommand(execInfo); + }.bind(this); + + // Ensure that execCommand has been wrapped correctly + this.wrapExecCommand(); + + // Add listener to list of execCommand listeners + this.options.ownerDocument.execCommand.listeners.push(this.execCommandListener); }, - //TODO: actionTemplate - toolbarButtons: function () { - var btns = this.options.buttons, - ul = this.options.ownerDocument.createElement('ul'), - li, - i, - btn, - ext; + // Remove our listener for calls to document.execCommand + detachExecCommand: function () { + var doc = this.options.ownerDocument; + if (!this.execCommandListener || !doc.execCommand.listeners) { + return; + } - ul.id = 'medium-editor-toolbar-actions' + this.id; - ul.className = 'medium-editor-toolbar-actions clearfix'; + // Find the index of this listener in the array of listeners so it can be removed + var index = doc.execCommand.listeners.indexOf(this.execCommandListener); + if (index !== -1) { + doc.execCommand.listeners.splice(index, 1); + } - for (i = 0; i < btns.length; i += 1) { - if (this.options.extensions.hasOwnProperty(btns[i])) { - ext = this.options.extensions[btns[i]]; - btn = ext.getButton !== undefined ? ext.getButton(this) : null; - if (ext.hasForm) { - btn.setAttribute('data-form', 'medium-editor-toolbar-form-' + btns[i] + '-' + this.id); - } - } else { - btn = this.buttonTemplate(btns[i]); + // If the list of listeners is now empty, put execCommand back to its original state + if (!doc.execCommand.listeners.length) { + this.unwrapExecCommand(); + } + }, + + // Wrap document.execCommand in a custom method so we can listen to calls to it + wrapExecCommand: function () { + var doc = this.options.ownerDocument; + + // Ensure all instance of MediumEditor only wrap execCommand once + if (doc.execCommand.listeners) { + return; + } + + // Create a wrapper method for execCommand which will: + // 1) Call document.execCommand with the correct arguments + // 2) Loop through any listeners and notify them that execCommand was called + // passing extra info on the call + // 3) Return the result + var wrapper = function (aCommandName, aShowDefaultUI, aValueArgument) { + var result = doc.execCommand.orig.apply(this, arguments); + + if (!doc.execCommand.listeners) { + return result; } - if (btn) { - li = this.options.ownerDocument.createElement('li'); - if (isElement(btn)) { - li.appendChild(btn); - } else { - li.innerHTML = btn; + var args = Array.prototype.slice.call(arguments); + doc.execCommand.listeners.forEach(function (listener) { + listener({ + command: aCommandName, + value: aValueArgument, + args: args, + result: result + }); + }); + + return result; + }; + + // Store a reference to the original execCommand + wrapper.orig = doc.execCommand; + + // Attach an array for storing listeners + wrapper.listeners = []; + + // Overwrite execCommand + doc.execCommand = wrapper; + }, + + // Revert document.execCommand back to its original self + unwrapExecCommand: function () { + var doc = this.options.ownerDocument; + if (!doc.execCommand.orig) { + return; + } + + // Use the reference to the original execCommand to revert back + doc.execCommand = doc.execCommand.orig; + }, + + // Listening to browser events to emit events medium-editor cares about + setupListener: function (name) { + if (this.listeners[name]) { + return; + } + + switch (name) { + case 'externalInteraction': + // Detecting when user has interacted with elements outside of MediumEditor + this.attachDOMEvent(this.options.ownerDocument.body, 'mousedown', this.handleBodyMousedown.bind(this), true); + this.attachDOMEvent(this.options.ownerDocument.body, 'click', this.handleBodyClick.bind(this), true); + this.attachDOMEvent(this.options.ownerDocument.body, 'focus', this.handleBodyFocus.bind(this), true); + break; + case 'blur': + // Detecting when focus is lost + this.setupListener('externalInteraction'); + break; + case 'focus': + // Detecting when focus moves into some part of MediumEditor + this.setupListener('externalInteraction'); + break; + case 'editableInput': + // setup cache for knowing when the content has changed + this.contentCache = []; + this.base.elements.forEach(function (element) { + this.contentCache[element.getAttribute('medium-editor-index')] = element.innerHTML; + + // Attach to the 'oninput' event, handled correctly by most browsers + if (this.InputEventOnContenteditableSupported) { + this.attachDOMEvent(element, 'input', this.handleInput.bind(this)); + } + }.bind(this)); + + // For browsers which don't support the input event on contenteditable (IE) + // we'll attach to 'selectionchange' on the document and 'keypress' on the editables + if (!this.InputEventOnContenteditableSupported) { + this.setupListener('editableKeypress'); + this.keypressUpdateInput = true; + this.attachDOMEvent(document, 'selectionchange', this.handleDocumentSelectionChange.bind(this)); + // Listen to calls to execCommand + this.attachToExecCommand(); } - ul.appendChild(li); + break; + case 'editableClick': + // Detecting click in the contenteditables + this.attachToEachElement('click', this.handleClick); + break; + case 'editableBlur': + // Detecting blur in the contenteditables + this.attachToEachElement('blur', this.handleBlur); + break; + case 'editableKeypress': + // Detecting keypress in the contenteditables + this.attachToEachElement('keypress', this.handleKeypress); + break; + case 'editableKeyup': + // Detecting keyup in the contenteditables + this.attachToEachElement('keyup', this.handleKeyup); + break; + case 'editableKeydown': + // Detecting keydown on the contenteditables + this.attachToEachElement('keydown', this.handleKeydown); + break; + case 'editableKeydownEnter': + // Detecting keydown for ENTER on the contenteditables + this.setupListener('editableKeydown'); + break; + case 'editableKeydownTab': + // Detecting keydown for TAB on the contenteditable + this.setupListener('editableKeydown'); + break; + case 'editableKeydownDelete': + // Detecting keydown for DELETE/BACKSPACE on the contenteditables + this.setupListener('editableKeydown'); + break; + case 'editableMouseover': + // Detecting mouseover on the contenteditables + this.attachToEachElement('mouseover', this.handleMouseover); + break; + case 'editableDrag': + // Detecting dragover and dragleave on the contenteditables + this.attachToEachElement('dragover', this.handleDragging); + this.attachToEachElement('dragleave', this.handleDragging); + break; + case 'editableDrop': + // Detecting drop on the contenteditables + this.attachToEachElement('drop', this.handleDrop); + break; + case 'editablePaste': + // Detecting paste on the contenteditables + this.attachToEachElement('paste', this.handlePaste); + break; + } + this.listeners[name] = true; + }, + + attachToEachElement: function (name, handler) { + this.base.elements.forEach(function (element) { + this.attachDOMEvent(element, name, handler.bind(this)); + }, this); + }, + + focusElement: function (element) { + element.focus(); + this.updateFocus(element, { target: element, type: 'focus' }); + }, + + updateFocus: function (target, eventObj) { + var toolbar = this.base.getExtensionByName('toolbar'), + toolbarEl = toolbar ? toolbar.getToolbarElement() : null, + anchorPreview = this.base.getExtensionByName('anchor-preview'), + previewEl = (anchorPreview && anchorPreview.getPreviewElement) ? anchorPreview.getPreviewElement() : null, + hadFocus = this.base.getFocusedElement(), + toFocus; + + // For clicks, we need to know if the mousedown that caused the click happened inside the existing focused element. + // If so, we don't want to focus another element + if (hadFocus && + eventObj.type === 'click' && + this.lastMousedownTarget && + (Util.isDescendant(hadFocus, this.lastMousedownTarget, true) || + Util.isDescendant(toolbarEl, this.lastMousedownTarget, true) || + Util.isDescendant(previewEl, this.lastMousedownTarget, true))) { + toFocus = hadFocus; + } + + if (!toFocus) { + this.base.elements.some(function (element) { + // If the target is part of an editor element, this is the element getting focus + if (!toFocus && (Util.isDescendant(element, target, true))) { + toFocus = element; + } + + // bail if we found an element that's getting focus + return !!toFocus; + }, this); + } + + // Check if the target is external (not part of the editor, toolbar, or anchorpreview) + var externalEvent = !Util.isDescendant(hadFocus, target, true) && + !Util.isDescendant(toolbarEl, target, true) && + !Util.isDescendant(previewEl, target, true); + + if (toFocus !== hadFocus) { + // If element has focus, and focus is going outside of editor + // Don't blur focused element if clicking on editor, toolbar, or anchorpreview + if (hadFocus && externalEvent) { + // Trigger blur on the editable that has lost focus + hadFocus.removeAttribute('data-medium-focused'); + this.triggerCustomEvent('blur', eventObj, hadFocus); } + + // If focus is going into an editor element + if (toFocus) { + // Trigger focus on the editable that now has focus + toFocus.setAttribute('data-medium-focused', true); + this.triggerCustomEvent('focus', eventObj, toFocus); + } } - return ul; + if (externalEvent) { + this.triggerCustomEvent('externalInteraction', eventObj); + } }, - addExtensionForms: function () { - var extensions = this.options.extensions, - ext, - name, - form, - id; + updateInput: function (target, eventObj) { + // An event triggered which signifies that the user may have changed someting + // Look in our cache of input for the contenteditables to see if something changed + var index = target.getAttribute('medium-editor-index'); + if (target.innerHTML !== this.contentCache[index]) { + // The content has changed since the last time we checked, fire the event + this.triggerCustomEvent('editableInput', eventObj, target); + } + this.contentCache[index] = target.innerHTML; + }, - for (name in extensions) { - if (extensions.hasOwnProperty(name)) { - ext = extensions[name]; - if (ext.hasForm) { - form = ext.getForm !== undefined ? ext.getForm() : null; + handleDocumentSelectionChange: function (event) { + // When selectionchange fires, target and current target are set + // to document, since this is where the event is handled + // However, currentTarget will have an 'activeElement' property + // which will point to whatever element has focus. + if (event.currentTarget && event.currentTarget.activeElement) { + var activeElement = event.currentTarget.activeElement, + currentTarget; + // We can look at the 'activeElement' to determine if the selectionchange has + // happened within a contenteditable owned by this instance of MediumEditor + this.base.elements.some(function (element) { + if (Util.isDescendant(element, activeElement, true)) { + currentTarget = element; + return true; } - if (form) { - id = 'medium-editor-toolbar-form-' + name + '-' + this.id; - form.className = 'medium-editor-toolbar-form'; - form.id = id; - ext.getForm().id = id; - this.toolbar.appendChild(form); - } + return false; + }, this); + + // We know selectionchange fired within one of our contenteditables + if (currentTarget) { + this.updateInput(currentTarget, { target: activeElement, currentTarget: currentTarget }); } } }, - toolbarFormAnchor: function () { - var anchor = this.options.ownerDocument.createElement('div'), - input = this.options.ownerDocument.createElement('input'), - target_label = this.options.ownerDocument.createElement('label'), - target = this.options.ownerDocument.createElement('input'), - button_label = this.options.ownerDocument.createElement('label'), - button = this.options.ownerDocument.createElement('input'), - close = this.options.ownerDocument.createElement('a'), - save = this.options.ownerDocument.createElement('a'); + handleDocumentExecCommand: function () { + // document.execCommand has been called + // If one of our contenteditables currently has focus, we should + // attempt to trigger the 'editableInput' event + var target = this.base.getFocusedElement(); + if (target) { + this.updateInput(target, { target: target, currentTarget: target }); + } + }, - close.setAttribute('href', '#'); - close.className = 'medium-editor-toobar-close'; - close.innerHTML = '&times;'; + handleBodyClick: function (event) { + this.updateFocus(event.target, event); + }, - save.setAttribute('href', '#'); - save.className = 'medium-editor-toobar-save'; - save.innerHTML = '&#10003;'; + handleBodyFocus: function (event) { + this.updateFocus(event.target, event); + }, - input.setAttribute('type', 'text'); - input.className = 'medium-editor-toolbar-input'; - input.setAttribute('placeholder', this.options.anchorInputPlaceholder); + handleBodyMousedown: function (event) { + this.lastMousedownTarget = event.target; + }, + handleInput: function (event) { + this.updateInput(event.currentTarget, event); + }, - target.setAttribute('type', 'checkbox'); - target.className = 'medium-editor-toolbar-anchor-target'; - target_label.innerHTML = this.options.anchorInputCheckboxLabel; - target_label.insertBefore(target, target_label.firstChild); + handleClick: function (event) { + this.triggerCustomEvent('editableClick', event, event.currentTarget); + }, - button.setAttribute('type', 'checkbox'); - button.className = 'medium-editor-toolbar-anchor-button'; - button_label.innerHTML = "Button"; - button_label.insertBefore(button, button_label.firstChild); + handleBlur: function (event) { + this.triggerCustomEvent('editableBlur', event, event.currentTarget); + }, + handleKeypress: function (event) { + this.triggerCustomEvent('editableKeypress', event, event.currentTarget); - anchor.className = 'medium-editor-toolbar-form'; - anchor.id = 'medium-editor-toolbar-form-anchor-' + this.id; - anchor.appendChild(input); + // If we're doing manual detection of the editableInput event we need + // to check for input changes during 'keypress' + if (this.keypressUpdateInput) { + var eventObj = { target: event.target, currentTarget: event.currentTarget }; - anchor.appendChild(save); - anchor.appendChild(close); + // In IE, we need to let the rest of the event stack complete before we detect + // changes to input, so using setTimeout here + setTimeout(function () { + this.updateInput(eventObj.currentTarget, eventObj); + }.bind(this), 0); + } + }, - if (this.options.anchorTarget) { - anchor.appendChild(target_label); + handleKeyup: function (event) { + this.triggerCustomEvent('editableKeyup', event, event.currentTarget); + }, + + handleMouseover: function (event) { + this.triggerCustomEvent('editableMouseover', event, event.currentTarget); + }, + + handleDragging: function (event) { + this.triggerCustomEvent('editableDrag', event, event.currentTarget); + }, + + handleDrop: function (event) { + this.triggerCustomEvent('editableDrop', event, event.currentTarget); + }, + + handlePaste: function (event) { + this.triggerCustomEvent('editablePaste', event, event.currentTarget); + }, + + handleKeydown: function (event) { + this.triggerCustomEvent('editableKeydown', event, event.currentTarget); + + if (Util.isKey(event, Util.keyCode.ENTER)) { + return this.triggerCustomEvent('editableKeydownEnter', event, event.currentTarget); } - if (this.options.anchorButton) { - anchor.appendChild(button_label); + if (Util.isKey(event, Util.keyCode.TAB)) { + return this.triggerCustomEvent('editableKeydownTab', event, event.currentTarget); } - return anchor; + if (Util.isKey(event, [Util.keyCode.DELETE, Util.keyCode.BACKSPACE])) { + return this.triggerCustomEvent('editableKeydownDelete', event, event.currentTarget); + } + } + }; +}()); + +var Button; +(function () { + 'use strict'; + + /*global Extension, buttonDefaults */ + + Button = Extension.extend({ + + /* Button Options */ + + /* action: [string] + * The action argument to pass to MediumEditor.execAction() + * when the button is clicked + */ + action: undefined, + + /* aria: [string] + * The value to add as the aria-label attribute of the button + * element displayed in the toolbar. + * This is also used as the tooltip for the button + */ + aria: undefined, + + /* tagNames: [Array] + * NOTE: This is not used if useQueryState is set to true. + * + * Array of element tag names that would indicate that this + * button has already been applied. If this action has already + * been applied, the button will be displayed as 'active' in the toolbar + * + * Example: + * For 'bold', if the text is ever within a <b> or <strong> + * tag that indicates the text is already bold. So the array + * of tagNames for bold would be: ['b', 'strong'] + */ + tagNames: undefined, + + /* style: [Object] + * NOTE: This is not used if useQueryState is set to true. + * + * A pair of css property & value(s) that indicate that this + * button has already been applied. If this action has already + * been applied, the button will be displayed as 'active' in the toolbar + * Properties of the object: + * prop [String]: name of the css property + * value [String]: value(s) of the css property + * multiple values can be separated by a '|' + * + * Example: + * For 'bold', if the text is ever within an element with a 'font-weight' + * style property set to '700' or 'bold', that indicates the text + * is already bold. So the style object for bold would be: + * { prop: 'font-weight', value: '700|bold' } + */ + style: undefined, + + /* useQueryState: [boolean] + * Enables/disables whether this button should use the built-in + * document.queryCommandState() method to determine whether + * the action has already been applied. If the action has already + * been applied, the button will be displayed as 'active' in the toolbar + * + * Example: + * For 'bold', if this is set to true, the code will call: + * document.queryCommandState('bold') which will return true if the + * browser thinks the text is already bold, and false otherwise + */ + useQueryState: undefined, + + /* contentDefault: [string] + * Default innerHTML to put inside the button + */ + contentDefault: undefined, + + /* contentFA: [string] + * The innerHTML to use for the content of the button + * if the `buttonLabels` option for MediumEditor is set to 'fontawesome' + */ + contentFA: undefined, + + /* classList: [Array] + * An array of classNames (strings) to be added to the button + */ + classList: undefined, + + /* attrs: [object] + * A set of key-value pairs to add to the button as custom attributes + */ + attrs: undefined, + + /* buttonDefaults: [Object] + * Set of default config options for all of the built-in MediumEditor buttons + */ + defaults: buttonDefaults, + + // The button constructor can optionally accept the name of a built-in button + // (ie 'bold', 'italic', etc.) + // When the name of a button is passed, it will initialize itself with the + // configuration for that button + constructor: function (options) { + if (Button.isBuiltInButton(options)) { + Extension.call(this, this.defaults[options]); + } else { + Extension.call(this, options); + } }, - bindSelect: function () { - var self = this, - i; + init: function () { + Extension.prototype.init.apply(this, arguments); - this.checkSelectionWrapper = function (e) { - // Do not close the toolbar when bluring the editable area and clicking into the anchor form - if (!self.options.disableAnchorForm && e && self.clickingIntoArchorForm(e)) { - return false; - } + this.button = this.createButton(); + this.on(this.button, 'click', this.handleClick.bind(this)); + }, - self.checkSelection(); - }; + /* getButton: [function ()] + * + * If implemented, this function will be called when + * the toolbar is being created. The DOM Element returned + * by this function will be appended to the toolbar along + * with any other buttons. + */ + getButton: function () { + return this.button; + }, - this.on(this.options.ownerDocument.documentElement, 'mouseup', this.checkSelectionWrapper); + getAction: function () { + return (typeof this.action === 'function') ? this.action(this.base.options) : this.action; + }, - for (i = 0; i < this.elements.length; i += 1) { - this.on(this.elements[i], 'keyup', this.checkSelectionWrapper); - this.on(this.elements[i], 'blur', this.checkSelectionWrapper); - this.on(this.elements[i], 'click', this.checkSelectionWrapper); + getAria: function () { + return (typeof this.aria === 'function') ? this.aria(this.base.options) : this.aria; + }, + + getTagNames: function () { + return (typeof this.tagNames === 'function') ? this.tagNames(this.base.options) : this.tagNames; + }, + + createButton: function () { + var button = this.document.createElement('button'), + content = this.contentDefault, + ariaLabel = this.getAria(), + buttonLabels = this.getEditorOption('buttonLabels'); + // Add class names + button.classList.add('medium-editor-action'); + button.classList.add('medium-editor-action-' + this.name); + if (this.classList) { + this.classList.forEach(function (className) { + button.classList.add(className); + }); } - return this; + + // Add attributes + button.setAttribute('data-action', this.getAction()); + if (ariaLabel) { + button.setAttribute('title', ariaLabel); + button.setAttribute('aria-label', ariaLabel); + } + if (this.attrs) { + Object.keys(this.attrs).forEach(function (attr) { + button.setAttribute(attr, this.attrs[attr]); + }, this); + } + + if (buttonLabels === 'fontawesome' && this.contentFA) { + content = this.contentFA; + } + button.innerHTML = content; + return button; }, - stopSelectionUpdates: function () { - this.preventSelectionUpdates = true; + handleClick: function (event) { + event.preventDefault(); + event.stopPropagation(); + + var action = this.getAction(); + + if (action) { + this.execAction(action); + } }, - startSelectionUpdates: function () { - this.preventSelectionUpdates = false; + isActive: function () { + return this.button.classList.contains(this.getEditorOption('activeButtonClass')); }, - checkSelection: function () { - var newSelection, - selectionElement; + setInactive: function () { + this.button.classList.remove(this.getEditorOption('activeButtonClass')); + delete this.knownState; + }, - if (!this.preventSelectionUpdates && - this.keepToolbarAlive !== true && - !this.options.disableToolbar) { + setActive: function () { + this.button.classList.add(this.getEditorOption('activeButtonClass')); + delete this.knownState; + }, - newSelection = this.options.contentWindow.getSelection(); - if ((!this.options.updateOnEmptySelection && newSelection.toString().trim() === '') || - (this.options.allowMultiParagraphSelection === false && this.hasMultiParagraphs()) || - this.selectionInContentEditableFalse()) { + queryCommandState: function () { + var queryState = null; + if (this.useQueryState) { + queryState = this.base.queryCommandState(this.getAction()); + } + return queryState; + }, - if (!this.options.staticToolbar) { - this.hideToolbarActions(); - } else if (this.anchorForm && this.anchorForm.style.display === 'block') { - this.setToolbarButtonStates(); - this.showToolbarActions(); - } + isAlreadyApplied: function (node) { + var isMatch = false, + tagNames = this.getTagNames(), + styleVals, + computedStyle; - } else { - selectionElement = this.getSelectionElement(); - if (!selectionElement || selectionElement.getAttribute('data-disable-toolbar')) { - if (!this.options.staticToolbar) { - this.hideToolbarActions(); + if (this.knownState === false || this.knownState === true) { + return this.knownState; + } + + if (tagNames && tagNames.length > 0) { + isMatch = tagNames.indexOf(node.nodeName.toLowerCase()) !== -1; + } + + if (!isMatch && this.style) { + styleVals = this.style.value.split('|'); + computedStyle = this.window.getComputedStyle(node, null).getPropertyValue(this.style.prop); + styleVals.forEach(function (val) { + if (!this.knownState) { + isMatch = (computedStyle.indexOf(val) !== -1); + // text-decoration is not inherited by default + // so if the computed style for text-decoration doesn't match + // don't write to knownState so we can fallback to other checks + if (isMatch || this.style.prop !== 'text-decoration') { + this.knownState = isMatch; } - } else { - this.checkSelectionElement(newSelection, selectionElement); } - } + }, this); } - return this; + + return isMatch; + } + }); + + Button.isBuiltInButton = function (name) { + return (typeof name === 'string') && Button.prototype.defaults.hasOwnProperty(name); + }; +}()); + +var FormExtension; +(function () { + 'use strict'; + + /* global Button */ + + var noop = function () {}; + + /* Base functionality for an extension whcih will display + * a 'form' inside the toolbar + */ + FormExtension = Button.extend({ + + init: function () { + Button.prototype.init.apply(this, arguments); }, - clickingIntoArchorForm: function (e) { - var self = this; + // default labels for the form buttons + formSaveLabel: '&#10003;', + formCloseLabel: '&times;', - if (e.type && e.type.toLowerCase() === 'blur' && e.relatedTarget && e.relatedTarget === self.anchorInput) { - return true; + /* hasForm: [boolean] + * + * Setting this to true will cause getForm() to be called + * when the toolbar is created, so the form can be appended + * inside the toolbar container + */ + hasForm: true, + + /* getForm: [function ()] + * + * When hasForm is true, this function must be implemented + * and return a DOM Element which will be appended to + * the toolbar container. The form should start hidden, and + * the extension can choose when to hide/show it + */ + getForm: noop, + + /* isDisplayed: [function ()] + * + * This function should return true/false reflecting + * whether the form is currently displayed + */ + isDisplayed: noop, + + /* hideForm: [function ()] + * + * This function should hide the form element inside + * the toolbar container + */ + hideForm: noop, + + /************************ Helpers ************************ + * The following are helpers that are either set by MediumEditor + * during initialization, or are helper methods which either + * route calls to the MediumEditor instance or provide common + * functionality for all form extensions + *********************************************************/ + + /* showToolbarDefaultActions: [function ()] + * + * Helper method which will turn back the toolbar after canceling + * the customized form + */ + showToolbarDefaultActions: function () { + var toolbar = this.base.getExtensionByName('toolbar'); + if (toolbar) { + toolbar.showToolbarDefaultActions(); } + }, - return false; + /* hideToolbarDefaultActions: [function ()] + * + * Helper function which will hide the default contents of the + * toolbar, but leave the toolbar container in the same state + * to allow a form to display its custom contents inside the toolbar + */ + hideToolbarDefaultActions: function () { + var toolbar = this.base.getExtensionByName('toolbar'); + if (toolbar) { + toolbar.hideToolbarDefaultActions(); + } }, - hasMultiParagraphs: function () { - var selectionHtml = getSelectionHtml.call(this).replace(/<[\S]+><\/[\S]+>/gim, ''), - hasMultiParagraphs = selectionHtml.match(/<(p|h[0-6]|blockquote)>([\s\S]*?)<\/(p|h[0-6]|blockquote)>/g); + /* setToolbarPosition: [function ()] + * + * Helper function which will update the size and position + * of the toolbar based on the toolbar content and the current + * position of the user's selection + */ + setToolbarPosition: function () { + var toolbar = this.base.getExtensionByName('toolbar'); + if (toolbar) { + toolbar.setToolbarPosition(); + } + } + }); +})(); +var AnchorForm; +(function () { + 'use strict'; - return (hasMultiParagraphs ? hasMultiParagraphs.length : 0); + /*global Util, Selection, FormExtension */ + + AnchorForm = FormExtension.extend({ + /* Anchor Form Options */ + + /* customClassOption: [string] (previously options.anchorButton + options.anchorButtonClass) + * Custom class name the user can optionally have added to their created links (ie 'button'). + * If passed as a non-empty string, a checkbox will be displayed allowing the user to choose + * whether to have the class added to the created link or not. + */ + customClassOption: null, + + /* customClassOptionText: [string] + * text to be shown in the checkbox when the __customClassOption__ is being used. + */ + customClassOptionText: 'Button', + + /* linkValidation: [boolean] (previously options.checkLinkFormat) + * enables/disables check for common URL protocols on anchor links. + */ + linkValidation: false, + + /* placeholderText: [string] (previously options.anchorInputPlaceholder) + * text to be shown as placeholder of the anchor input. + */ + placeholderText: 'Paste or type a link', + + /* targetCheckbox: [boolean] (previously options.anchorTarget) + * enables/disables displaying a "Open in new window" checkbox, which when checked + * changes the `target` attribute of the created link. + */ + targetCheckbox: false, + + /* targetCheckboxText: [string] (previously options.anchorInputCheckboxLabel) + * text to be shown in the checkbox enabled via the __targetCheckbox__ option. + */ + targetCheckboxText: 'Open in new window', + + // Options for the Button base class + name: 'anchor', + action: 'createLink', + aria: 'link', + tagNames: ['a'], + contentDefault: '<b>#</b>', + contentFA: '<i class="fa fa-link"></i>', + + init: function () { + FormExtension.prototype.init.apply(this, arguments); + + this.subscribe('editableKeydown', this.handleKeydown.bind(this)); }, - checkSelectionElement: function (newSelection, selectionElement) { - var i, - adjacentNode, - offset = 0, - newRange; - this.selection = newSelection; - this.selectionRange = this.selection.getRangeAt(0); + // Called when the button the toolbar is clicked + // Overrides ButtonExtension.handleClick + handleClick: function (event) { + event.preventDefault(); + event.stopPropagation(); - /* - * In firefox, there are cases (ie doubleclick of a word) where the selectionRange start - * will be at the very end of an element. In other browsers, the selectionRange start - * would instead be at the very beginning of an element that actually has content. - * example: - * <span>foo</span><span>bar</span> - * - * If the text 'bar' is selected, most browsers will have the selectionRange start at the beginning - * of the 'bar' span. However, there are cases where firefox will have the selectionRange start - * at the end of the 'foo' span. The contenteditable behavior will be ok, but if there are any - * properties on the 'bar' span, they won't be reflected accurately in the toolbar - * (ie 'Bold' button wouldn't be active) - * - * So, for cases where the selectionRange start is at the end of an element/node, find the next - * adjacent text node that actually has content in it, and move the selectionRange start there. - */ - if (this.options.standardizeSelectionStart && - this.selectionRange.startContainer.nodeValue && - (this.selectionRange.startOffset === this.selectionRange.startContainer.nodeValue.length)) { - adjacentNode = findAdjacentTextNodeWithContent(this.getSelectionElement(), this.selectionRange.startContainer, this.options.ownerDocument); - if (adjacentNode) { - offset = 0; - while (adjacentNode.nodeValue.substr(offset, 1).trim().length === 0) { - offset = offset + 1; - } - newRange = this.options.ownerDocument.createRange(); - newRange.setStart(adjacentNode, offset); - newRange.setEnd(this.selectionRange.endContainer, this.selectionRange.endOffset); - this.selection.removeAllRanges(); - this.selection.addRange(newRange); - this.selectionRange = newRange; - } + var selectedParentElement = Selection.getSelectedParentElement(Selection.getSelectionRange(this.document)), + firstTextNode = Util.getFirstTextNode(selectedParentElement); + + if (Util.getClosestTag(firstTextNode, 'a')) { + return this.execAction('unlink'); } - for (i = 0; i < this.elements.length; i += 1) { - if (this.elements[i] === selectionElement) { - this.setToolbarButtonStates() - .setToolbarPosition() - .showToolbarActions(); - return; - } + if (!this.isDisplayed()) { + this.showForm(); } - if (!this.options.staticToolbar) { - this.hideToolbarActions(); + return false; + }, + + // Called when user hits the defined shortcut (CTRL / COMMAND + K) + handleKeydown: function (event) { + if (Util.isKey(event, Util.keyCode.K) && Util.isMetaCtrlKey(event) && !event.shiftKey) { + this.handleClick(event); } }, - findMatchingSelectionParent: function (testElementFunction) { - var selection = this.options.contentWindow.getSelection(), range, current; + // Called by medium-editor to append form to the toolbar + getForm: function () { + if (!this.form) { + this.form = this.createForm(); + } + return this.form; + }, - if (selection.rangeCount === 0) { + getTemplate: function () { + var template = [ + '<input type="text" class="medium-editor-toolbar-input" placeholder="', this.placeholderText, '">' + ]; + + template.push( + '<a href="#" class="medium-editor-toolbar-save">', + this.getEditorOption('buttonLabels') === 'fontawesome' ? '<i class="fa fa-check"></i>' : this.formSaveLabel, + '</a>' + ); + + template.push('<a href="#" class="medium-editor-toolbar-close">', + this.getEditorOption('buttonLabels') === 'fontawesome' ? '<i class="fa fa-times"></i>' : this.formCloseLabel, + '</a>'); + + // both of these options are slightly moot with the ability to + // override the various form buildup/serialize functions. + + if (this.targetCheckbox) { + // fixme: ideally, this targetCheckboxText would be a formLabel too, + // figure out how to deprecate? also consider `fa-` icon default implcations. + template.push( + '<input type="checkbox" class="medium-editor-toolbar-anchor-target">', + '<label>', + this.targetCheckboxText, + '</label>' + ); + } + + if (this.customClassOption) { + // fixme: expose this `Button` text as a formLabel property, too + // and provide similar access to a `fa-` icon default. + template.push( + '<input type="checkbox" class="medium-editor-toolbar-anchor-button">', + '<label>', + this.customClassOptionText, + '</label>' + ); + } + + return template.join(''); + + }, + + // Used by medium-editor when the default toolbar is to be displayed + isDisplayed: function () { + return this.getForm().style.display === 'block'; + }, + + hideForm: function () { + this.getForm().style.display = 'none'; + this.getInput().value = ''; + }, + + showForm: function (linkValue) { + var input = this.getInput(); + + this.base.saveSelection(); + this.hideToolbarDefaultActions(); + this.getForm().style.display = 'block'; + this.setToolbarPosition(); + + input.value = linkValue || ''; + input.focus(); + }, + + // Called by core when tearing down medium-editor (destroy) + destroy: function () { + if (!this.form) { return false; } - range = selection.getRangeAt(0); - current = range.commonAncestorContainer; + if (this.form.parentNode) { + this.form.parentNode.removeChild(this.form); + } - do { - if (current.nodeType === 1) { - if (testElementFunction(current)) { - return current; - } - // do not traverse upwards past the nearest containing editor - if (current.getAttribute('data-medium-element')) { - return false; - } - } + delete this.form; + }, - current = current.parentNode; - } while (current); + // core methods - return false; + getFormOpts: function () { + // no notion of private functions? wanted `_getFormOpts` + var targetCheckbox = this.getForm().querySelector('.medium-editor-toolbar-anchor-target'), + buttonCheckbox = this.getForm().querySelector('.medium-editor-toolbar-anchor-button'), + opts = { + url: this.getInput().value + }; + + if (this.linkValidation) { + opts.url = this.checkLinkFormat(opts.url); + } + + opts.target = '_self'; + if (targetCheckbox && targetCheckbox.checked) { + opts.target = '_blank'; + } + + if (buttonCheckbox && buttonCheckbox.checked) { + opts.buttonClass = this.customClassOption; + } + + return opts; }, - getSelectionElement: function () { - return this.findMatchingSelectionParent(function (el) { - return el.getAttribute('data-medium-element'); - }); + doFormSave: function () { + var opts = this.getFormOpts(); + this.completeFormSave(opts); }, - selectionInContentEditableFalse: function () { - return this.findMatchingSelectionParent(function (el) { - return (el && el.nodeName !== '#text' && el.getAttribute('contenteditable') === 'false'); - }); + completeFormSave: function (opts) { + this.base.restoreSelection(); + this.execAction(this.action, opts); + this.base.checkSelection(); }, - setToolbarPosition: function () { - // document.documentElement for IE 9 - var scrollTop = (this.options.ownerDocument.documentElement && this.options.ownerDocument.documentElement.scrollTop) || this.options.ownerDocument.body.scrollTop, - container = this.elements[0], - containerRect = container.getBoundingClientRect(), - containerTop = containerRect.top + scrollTop, - buttonHeight = 50, - selection = this.options.contentWindow.getSelection(), - range, - boundary, - middleBoundary, - defaultLeft = (this.options.diffLeft) - (this.toolbar.offsetWidth / 2), - halfOffsetWidth = this.toolbar.offsetWidth / 2, - containerCenter = (containerRect.left + (containerRect.width / 2)); + checkLinkFormat: function (value) { + var re = /^(https?|ftps?|rtmpt?):\/\/|mailto:/; + return (re.test(value) ? '' : 'http://') + value; + }, - if (selection.focusNode === null) { - return this; + doFormCancel: function () { + this.base.restoreSelection(); + this.base.checkSelection(); + }, + + // form creation and event handling + attachFormEvents: function (form) { + var close = form.querySelector('.medium-editor-toolbar-close'), + save = form.querySelector('.medium-editor-toolbar-save'), + input = form.querySelector('.medium-editor-toolbar-input'); + + // Handle clicks on the form itself + this.on(form, 'click', this.handleFormClick.bind(this)); + + // Handle typing in the textbox + this.on(input, 'keyup', this.handleTextboxKeyup.bind(this)); + + // Handle close button clicks + this.on(close, 'click', this.handleCloseClick.bind(this)); + + // Handle save button clicks (capture) + this.on(save, 'click', this.handleSaveClick.bind(this), true); + + }, + + createForm: function () { + var doc = this.document, + form = doc.createElement('div'); + + // Anchor Form (div) + form.className = 'medium-editor-toolbar-form'; + form.id = 'medium-editor-toolbar-form-anchor-' + this.getEditorId(); + form.innerHTML = this.getTemplate(); + this.attachFormEvents(form); + + return form; + }, + + getInput: function () { + return this.getForm().querySelector('input.medium-editor-toolbar-input'); + }, + + handleTextboxKeyup: function (event) { + // For ENTER -> create the anchor + if (event.keyCode === Util.keyCode.ENTER) { + event.preventDefault(); + this.doFormSave(); + return; } - this.showToolbar(); + // For ESCAPE -> close the form + if (event.keyCode === Util.keyCode.ESCAPE) { + event.preventDefault(); + this.doFormCancel(); + } + }, - if (this.options.staticToolbar) { + handleFormClick: function (event) { + // make sure not to hide form when clicking inside the form + event.stopPropagation(); + }, - if (this.options.stickyToolbar) { + handleSaveClick: function (event) { + // Clicking Save -> create the anchor + event.preventDefault(); + this.doFormSave(); + }, - // If it's beyond the height of the editor, position it at the bottom of the editor - if (scrollTop > (containerTop + this.elements[0].offsetHeight - this.toolbar.offsetHeight)) { - this.toolbar.style.top = (containerTop + this.elements[0].offsetHeight) + 'px'; + handleCloseClick: function (event) { + // Click Close -> close the form + event.preventDefault(); + this.doFormCancel(); + } + }); +}()); +var AnchorPreview; +(function () { + 'use strict'; - // Stick the toolbar to the top of the window - } else if (scrollTop > (containerTop - this.toolbar.offsetHeight)) { - this.toolbar.classList.add('sticky-toolbar'); - this.toolbar.style.top = "0px"; - // Normal static toolbar position - } else { - this.toolbar.classList.remove('sticky-toolbar'); - this.toolbar.style.top = containerTop - this.toolbar.offsetHeight + "px"; - } + /*global Util, Extension */ - } else { - this.toolbar.style.top = containerTop - this.toolbar.offsetHeight + "px"; - } + AnchorPreview = Extension.extend({ + name: 'anchor-preview', - if (this.options.toolbarAlign) { - if (this.options.toolbarAlign === 'left') { - this.toolbar.style.left = containerRect.left + "px"; - } else if (this.options.toolbarAlign === 'center') { - this.toolbar.style.left = (containerCenter - halfOffsetWidth) + "px"; - } else { - this.toolbar.style.left = (containerRect.right - this.toolbar.offsetWidth) + "px"; - } - } else { - this.toolbar.style.left = (containerCenter - halfOffsetWidth) + "px"; - } + // Anchor Preview Options - } else if (!selection.isCollapsed) { - range = selection.getRangeAt(0); - boundary = range.getBoundingClientRect(); - middleBoundary = (boundary.left + boundary.right) / 2; + /* hideDelay: [number] (previously options.anchorPreviewHideDelay) + * time in milliseconds to show the anchor tag preview after the mouse has left the anchor tag. + */ + hideDelay: 500, - if (boundary.top < buttonHeight) { - this.toolbar.classList.add('medium-toolbar-arrow-over'); - this.toolbar.classList.remove('medium-toolbar-arrow-under'); - this.toolbar.style.top = buttonHeight + boundary.bottom - this.options.diffTop + this.options.contentWindow.pageYOffset - this.toolbar.offsetHeight + 'px'; - } else { - this.toolbar.classList.add('medium-toolbar-arrow-under'); - this.toolbar.classList.remove('medium-toolbar-arrow-over'); - this.toolbar.style.top = boundary.top + this.options.diffTop + this.options.contentWindow.pageYOffset - this.toolbar.offsetHeight + 'px'; + /* previewValueSelector: [string] + * the default selector to locate where to put the activeAnchor value in the preview + */ + previewValueSelector: 'a', + + init: function () { + this.anchorPreview = this.createPreview(); + + this.getEditorOption('elementsContainer').appendChild(this.anchorPreview); + + this.attachToEditables(); + }, + + getPreviewElement: function () { + return this.anchorPreview; + }, + + createPreview: function () { + var el = this.document.createElement('div'); + + el.id = 'medium-editor-anchor-preview-' + this.getEditorId(); + el.className = 'medium-editor-anchor-preview'; + el.innerHTML = this.getTemplate(); + + this.on(el, 'click', this.handleClick.bind(this)); + + return el; + }, + + getTemplate: function () { + return '<div class="medium-editor-toolbar-anchor-preview" id="medium-editor-toolbar-anchor-preview">' + + ' <a class="medium-editor-toolbar-anchor-preview-inner"></a>' + + '</div>'; + }, + + destroy: function () { + if (this.anchorPreview) { + if (this.anchorPreview.parentNode) { + this.anchorPreview.parentNode.removeChild(this.anchorPreview); } - if (middleBoundary < halfOffsetWidth) { - this.toolbar.style.left = defaultLeft + halfOffsetWidth + 'px'; - } else if ((this.options.contentWindow.innerWidth - middleBoundary) < halfOffsetWidth) { - this.toolbar.style.left = this.options.contentWindow.innerWidth + defaultLeft - halfOffsetWidth + 'px'; - } else { - this.toolbar.style.left = defaultLeft + middleBoundary + 'px'; - } + delete this.anchorPreview; } + }, - this.hideAnchorPreview(); + hidePreview: function () { + this.anchorPreview.classList.remove('medium-editor-anchor-preview-active'); + this.activeAnchor = null; + }, + showPreview: function (anchorEl) { + if (this.anchorPreview.classList.contains('medium-editor-anchor-preview-active') || + anchorEl.getAttribute('data-disable-preview')) { + return true; + } + + if (this.previewValueSelector) { + this.anchorPreview.querySelector(this.previewValueSelector).textContent = anchorEl.attributes.href.value; + this.anchorPreview.querySelector(this.previewValueSelector).href = anchorEl.attributes.href.value; + } + + this.anchorPreview.classList.add('medium-toolbar-arrow-over'); + this.anchorPreview.classList.remove('medium-toolbar-arrow-under'); + + if (!this.anchorPreview.classList.contains('medium-editor-anchor-preview-active')) { + this.anchorPreview.classList.add('medium-editor-anchor-preview-active'); + } + + this.activeAnchor = anchorEl; + + this.positionPreview(); + this.attachPreviewHandlers(); + return this; }, - setToolbarButtonStates: function () { - var buttons = this.toolbarActions.querySelectorAll('button'), - i; - for (i = 0; i < buttons.length; i += 1) { - buttons[i].classList.remove(this.options.activeButtonClass); + positionPreview: function (activeAnchor) { + activeAnchor = activeAnchor || this.activeAnchor; + var buttonHeight = this.anchorPreview.offsetHeight, + boundary = activeAnchor.getBoundingClientRect(), + middleBoundary = (boundary.left + boundary.right) / 2, + diffLeft = this.diffLeft, + diffTop = this.diffTop, + halfOffsetWidth, + defaultLeft; + + halfOffsetWidth = this.anchorPreview.offsetWidth / 2; + var toolbarExtension = this.base.getExtensionByName('toolbar'); + if (toolbarExtension) { + diffLeft = toolbarExtension.diffLeft; + diffTop = toolbarExtension.diffTop; } - this.checkActiveButtons(); - return this; + defaultLeft = diffLeft - halfOffsetWidth; + + this.anchorPreview.style.top = Math.round(buttonHeight + boundary.bottom - diffTop + this.window.pageYOffset - this.anchorPreview.offsetHeight) + 'px'; + if (middleBoundary < halfOffsetWidth) { + this.anchorPreview.style.left = defaultLeft + halfOffsetWidth + 'px'; + } else if ((this.window.innerWidth - middleBoundary) < halfOffsetWidth) { + this.anchorPreview.style.left = this.window.innerWidth + defaultLeft - halfOffsetWidth + 'px'; + } else { + this.anchorPreview.style.left = defaultLeft + middleBoundary + 'px'; + } }, - checkActiveButtons: function () { - var elements = Array.prototype.slice.call(this.elements), - parentNode = this.getSelectedParentElement(); - while (parentNode.tagName !== undefined && this.parentElements.indexOf(parentNode.tagName.toLowerCase) === -1) { - this.activateButton(parentNode.tagName.toLowerCase()); - this.callExtensions('checkState', parentNode); + attachToEditables: function () { + this.subscribe('editableMouseover', this.handleEditableMouseover.bind(this)); + }, - // we can abort the search upwards if we leave the contentEditable element - if (elements.indexOf(parentNode) !== -1) { - break; - } - parentNode = parentNode.parentNode; + handleClick: function (event) { + var anchorExtension = this.base.getExtensionByName('anchor'), + activeAnchor = this.activeAnchor; + + if (anchorExtension && activeAnchor) { + event.preventDefault(); + + this.base.selectElement(this.activeAnchor); + + // Using setTimeout + delay because: + // We may actually be displaying the anchor form, which should be controlled by delay + this.base.delay(function () { + if (activeAnchor) { + anchorExtension.showForm(activeAnchor.attributes.href.value); + activeAnchor = null; + } + }.bind(this)); } + + this.hidePreview(); }, - activateButton: function (tag) { - var el = this.toolbar.querySelector('[data-element="' + tag + '"]'); - if (el !== null && el.className.indexOf(this.options.activeButtonClass) === -1) { - el.className += ' ' + this.options.activeButtonClass; + handleAnchorMouseout: function () { + this.anchorToPreview = null; + this.off(this.activeAnchor, 'mouseout', this.instanceHandleAnchorMouseout); + this.instanceHandleAnchorMouseout = null; + }, + + handleEditableMouseover: function (event) { + var target = Util.getClosestTag(event.target, 'a'); + + if (false === target) { + return; } + + // Detect empty href attributes + // The browser will make href="" or href="#top" + // into absolute urls when accessed as event.targed.href, so check the html + if (!/href=["']\S+["']/.test(target.outerHTML) || /href=["']#\S+["']/.test(target.outerHTML)) { + return true; + } + + // only show when toolbar is not present + var toolbar = this.base.getExtensionByName('toolbar'); + if (toolbar && toolbar.isDisplayed && toolbar.isDisplayed()) { + return true; + } + + // detach handler for other anchor in case we hovered multiple anchors quickly + if (this.activeAnchor && this.activeAnchor !== target) { + this.detachPreviewHandlers(); + } + + this.anchorToPreview = target; + + this.instanceHandleAnchorMouseout = this.handleAnchorMouseout.bind(this); + this.on(this.anchorToPreview, 'mouseout', this.instanceHandleAnchorMouseout); + // Using setTimeout + delay because: + // - We're going to show the anchor preview according to the configured delay + // if the mouse has not left the anchor tag in that time + this.base.delay(function () { + if (this.anchorToPreview) { + this.showPreview(this.anchorToPreview); + } + }.bind(this)); }, - bindButtons: function () { - var buttons = this.toolbar.querySelectorAll('button'), - i, - self = this, - triggerAction = function (e) { - e.preventDefault(); - e.stopPropagation(); - if (self.selection === undefined) { - self.checkSelection(); - } - if (this.className.indexOf(self.options.activeButtonClass) > -1) { - this.classList.remove(self.options.activeButtonClass); - } else { - this.className += ' ' + self.options.activeButtonClass; - } - if (this.hasAttribute('data-action')) { - self.execAction(this.getAttribute('data-action'), e); - } - // Allows extension buttons to show a form - // TO DO: Improve this - if (this.hasAttribute('data-form')) { - self.showForm(this.getAttribute('data-form'), e); - } - }; - for (i = 0; i < buttons.length; i += 1) { - this.on(buttons[i], 'click', triggerAction); + handlePreviewMouseover: function () { + this.lastOver = (new Date()).getTime(); + this.hovering = true; + }, + + handlePreviewMouseout: function (event) { + if (!event.relatedTarget || !/anchor-preview/.test(event.relatedTarget.className)) { + this.hovering = false; } - this.setFirstAndLastItems(buttons); - return this; }, - setFirstAndLastItems: function (buttons) { - if (buttons.length > 0) { - buttons[0].className += ' ' + this.options.firstButtonClass; - buttons[buttons.length - 1].className += ' ' + this.options.lastButtonClass; + updatePreview: function () { + if (this.hovering) { + return true; } - return this; + var durr = (new Date()).getTime() - this.lastOver; + if (durr > this.hideDelay) { + // hide the preview 1/2 second after mouse leaves the link + this.detachPreviewHandlers(); + } }, - execAction: function (action, e) { - if (action.indexOf('append-') > -1) { - this.execFormatBlock(action.replace('append-', '')); - this.setToolbarPosition(); - this.setToolbarButtonStates(); - } else if (action === 'anchor') { - if (!this.options.disableAnchorForm) { - this.triggerAnchorAction(e); + detachPreviewHandlers: function () { + // cleanup + clearInterval(this.intervalTimer); + if (this.instanceHandlePreviewMouseover) { + this.off(this.anchorPreview, 'mouseover', this.instanceHandlePreviewMouseover); + this.off(this.anchorPreview, 'mouseout', this.instanceHandlePreviewMouseout); + if (this.activeAnchor) { + this.off(this.activeAnchor, 'mouseover', this.instanceHandlePreviewMouseover); + this.off(this.activeAnchor, 'mouseout', this.instanceHandlePreviewMouseout); } - } else if (action === 'image') { - this.options.ownerDocument.execCommand('insertImage', false, this.options.contentWindow.getSelection()); - } else { - this.options.ownerDocument.execCommand(action, false, null); - this.setToolbarPosition(); } + + this.hidePreview(); + + this.hovering = this.instanceHandlePreviewMouseover = this.instanceHandlePreviewMouseout = null; }, - // Method to show an extension's form - // TO DO: Improve this - showForm: function (formId, e) { - this.toolbarActions.style.display = 'none'; - this.saveSelection(); - var form = document.getElementById(formId); - form.style.display = 'block'; - this.setToolbarPosition(); - this.keepToolbarAlive = true; + // TODO: break up method and extract out handlers + attachPreviewHandlers: function () { + this.lastOver = (new Date()).getTime(); + this.hovering = true; + + this.instanceHandlePreviewMouseover = this.handlePreviewMouseover.bind(this); + this.instanceHandlePreviewMouseout = this.handlePreviewMouseout.bind(this); + + this.intervalTimer = setInterval(this.updatePreview.bind(this), 200); + + this.on(this.anchorPreview, 'mouseover', this.instanceHandlePreviewMouseover); + this.on(this.anchorPreview, 'mouseout', this.instanceHandlePreviewMouseout); + this.on(this.activeAnchor, 'mouseover', this.instanceHandlePreviewMouseover); + this.on(this.activeAnchor, 'mouseout', this.instanceHandlePreviewMouseout); + } + }); +}()); + +var AutoLink, + WHITESPACE_CHARS, + KNOWN_TLDS_FRAGMENT, + LINK_REGEXP_TEXT; + +WHITESPACE_CHARS = [' ', '\t', '\n', '\r', '\u00A0', '\u2000', '\u2001', '\u2002', '\u2003', + '\u2028', '\u2029']; +KNOWN_TLDS_FRAGMENT = 'com|net|org|edu|gov|mil|aero|asia|biz|cat|coop|info|int|jobs|mobi|museum|name|post|pro|tel|travel|' + + 'xxx|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|' + + 'bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|eu|fi|fj|' + + 'fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|' + + 'is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|' + + 'mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|' + + 'pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|ja|sk|sl|sm|sn|so|sr|ss|st|su|sv|sx|sy|sz|tc|td|tf|tg|th|' + + 'tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|yu|za|zm|zw'; +LINK_REGEXP_TEXT = + '(' + + // Version of Gruber URL Regexp optimized for JS: http://stackoverflow.com/a/17733640 + '((?:(https?://|ftps?://|nntp://)|www\\d{0,3}[.]|[a-z0-9.\\-]+[.](' + KNOWN_TLDS_FRAGMENT + ')\\\/)\\S+(?:[^\\s`!\\[\\]{};:\'\".,?\u00AB\u00BB\u201C\u201D\u2018\u2019]))' + + // Addition to above Regexp to support bare domains/one level subdomains with common non-i18n TLDs and without www prefix: + ')|(([a-z0-9\\-]+\\.)?[a-z0-9\\-]+\\.(' + KNOWN_TLDS_FRAGMENT + '))'; + +(function () { + 'use strict'; + + var KNOWN_TLDS_REGEXP = new RegExp('^(' + KNOWN_TLDS_FRAGMENT + ')$', 'i'); + + function nodeIsNotInsideAnchorTag(node) { + return !Util.getClosestTag(node, 'a'); + } + + AutoLink = Extension.extend({ + init: function () { + Extension.prototype.init.apply(this, arguments); + + this.disableEventHandling = false; + this.subscribe('editableKeypress', this.onKeypress.bind(this)); + this.subscribe('editableBlur', this.onBlur.bind(this)); + // MS IE has it's own auto-URL detect feature but ours is better in some ways. Be consistent. + this.document.execCommand('AutoUrlDetect', false, false); }, - // Method to show an extension's form - // TO DO: Improve this - hideForm: function (form, e) { - var el = document.getElementById(form.id); - el.style.display = 'none'; - this.showToolbarActions(); - this.setToolbarPosition(); - restoreSelection.call(this, this.savedSelection); + destroy: function () { + // Turn AutoUrlDetect back on + if (this.document.queryCommandSupported('AutoUrlDetect')) { + this.document.execCommand('AutoUrlDetect', false, true); + } }, - // http://stackoverflow.com/questions/15867542/range-object-get-selection-parent-node-chrome-vs-firefox - rangeSelectsSingleNode: function (range) { - var startNode = range.startContainer; - return startNode === range.endContainer && - startNode.hasChildNodes() && - range.endOffset === range.startOffset + 1; + onBlur: function (blurEvent, editable) { + this.performLinking(editable); }, - getSelectedParentElement: function () { - var selectedParentElement = null, - range = this.selectionRange; - if (this.rangeSelectsSingleNode(range) && range.startContainer.childNodes[range.startOffset].nodeType !== 3) { - selectedParentElement = range.startContainer.childNodes[range.startOffset]; - } else if (range.startContainer.nodeType === 3) { - selectedParentElement = range.startContainer.parentNode; - } else { - selectedParentElement = range.startContainer; + onKeypress: function (keyPressEvent) { + if (this.disableEventHandling) { + return; } - return selectedParentElement; - }, - triggerAnchorAction: function () { - var selectedParentElement = this.getSelectedParentElement(); - if (selectedParentElement.tagName && - selectedParentElement.tagName.toLowerCase() === 'a') { - this.options.ownerDocument.execCommand('unlink', false, null); - } else if (this.anchorForm) { - if (this.anchorForm.style.display === 'block') { - this.showToolbarActions(); - } else { - this.showAnchorForm(); - } + if (Util.isKey(keyPressEvent, [Util.keyCode.SPACE, Util.keyCode.ENTER])) { + clearTimeout(this.performLinkingTimeout); + // Saving/restoring the selection in the middle of a keypress doesn't work well... + this.performLinkingTimeout = setTimeout(function () { + try { + var sel = this.base.exportSelection(); + if (this.performLinking(keyPressEvent.target)) { + // pass true for favorLaterSelectionAnchor - this is needed for links at the end of a + // paragraph in MS IE, or MS IE causes the link to be deleted right after adding it. + this.base.importSelection(sel, true); + } + } catch (e) { + if (window.console) { + window.console.error('Failed to perform linking', e); + } + this.disableEventHandling = true; + } + }.bind(this), 0); } - return this; }, - execFormatBlock: function (el) { - var selectionData = this.getSelectionData(this.selection.anchorNode); - // FF handles blockquote differently on formatBlock - // allowing nesting, we need to use outdent - // https://developer.mozilla.org/en-US/docs/Rich-Text_Editing_in_Mozilla - if (el === 'blockquote' && selectionData.el && - selectionData.el.parentNode.tagName.toLowerCase() === 'blockquote') { - return this.options.ownerDocument.execCommand('outdent', false, null); + performLinking: function (contenteditable) { + // Perform linking on a paragraph level basis as otherwise the detection can wrongly find the end + // of one paragraph and the beginning of another paragraph to constitute a link, such as a paragraph ending + // "link." and the next paragraph beginning with "my" is interpreted into "link.my" and the code tries to create + // a link across paragraphs - which doesn't work and is terrible. + // (Medium deletes the spaces/returns between P tags so the textContent ends up without paragraph spacing) + var paragraphs = contenteditable.querySelectorAll('p'), + documentModified = false; + if (paragraphs.length === 0) { + paragraphs = [contenteditable]; } - if (selectionData.tagName === el) { - el = 'p'; + for (var i = 0; i < paragraphs.length; i++) { + documentModified = this.removeObsoleteAutoLinkSpans(paragraphs[i]) || documentModified; + documentModified = this.performLinkingWithinElement(paragraphs[i]) || documentModified; } - // When IE we need to add <> to heading elements and - // blockquote needs to be called as indent - // http://stackoverflow.com/questions/10741831/execcommand-formatblock-headings-in-ie - // http://stackoverflow.com/questions/1816223/rich-text-editor-with-blockquote-function/1821777#1821777 - if (this.isIE) { - if (el === 'blockquote') { - return this.options.ownerDocument.execCommand('indent', false, el); + return documentModified; + }, + + removeObsoleteAutoLinkSpans: function (element) { + var spans = element.querySelectorAll('span[data-auto-link="true"]'), + documentModified = false; + + for (var i = 0; i < spans.length; i++) { + var textContent = spans[i].textContent; + if (textContent.indexOf('://') === -1) { + textContent = Util.ensureUrlHasProtocol(textContent); } - el = '<' + el + '>'; + if (spans[i].getAttribute('data-href') !== textContent && nodeIsNotInsideAnchorTag(spans[i])) { + documentModified = true; + var trimmedTextContent = textContent.replace(/\s+$/, ''); + if (spans[i].getAttribute('data-href') === trimmedTextContent) { + var charactersTrimmed = textContent.length - trimmedTextContent.length, + subtree = Util.splitOffDOMTree(spans[i], this.splitTextBeforeEnd(spans[i], charactersTrimmed)); + spans[i].parentNode.insertBefore(subtree, spans[i].nextSibling); + } else { + // Some editing has happened to the span, so just remove it entirely. The user can put it back + // around just the href content if they need to prevent it from linking + Util.unwrap(spans[i], this.document); + } + } } - return this.options.ownerDocument.execCommand('formatBlock', false, el); + return documentModified; }, - getSelectionData: function (el) { - var tagName; + splitTextBeforeEnd: function (element, characterCount) { + var treeWalker = this.document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false), + lastChildNotExhausted = true; - if (el && el.tagName) { - tagName = el.tagName.toLowerCase(); + // Start the tree walker at the last descendant of the span + while (lastChildNotExhausted) { + lastChildNotExhausted = treeWalker.lastChild() !== null; } - while (el && this.parentElements.indexOf(tagName) === -1) { - el = el.parentNode; - if (el && el.tagName) { - tagName = el.tagName.toLowerCase(); + var currentNode, + currentNodeValue, + previousNode; + while (characterCount > 0 && previousNode !== null) { + currentNode = treeWalker.currentNode; + currentNodeValue = currentNode.nodeValue; + if (currentNodeValue.length > characterCount) { + previousNode = currentNode.splitText(currentNodeValue.length - characterCount); + characterCount = 0; + } else { + previousNode = treeWalker.previousNode(); + characterCount -= currentNodeValue.length; } } - - return { - el: el, - tagName: tagName - }; + return previousNode; }, - getFirstChild: function (el) { - var firstChild = el.firstChild; - while (firstChild !== null && firstChild.nodeType !== 1) { - firstChild = firstChild.nextSibling; + performLinkingWithinElement: function (element) { + var matches = this.findLinkableText(element), + linkCreated = false; + + for (var matchIndex = 0; matchIndex < matches.length; matchIndex++) { + var matchingTextNodes = Util.findOrCreateMatchingTextNodes(this.document, element, + matches[matchIndex]); + if (this.shouldNotLink(matchingTextNodes)) { + continue; + } + this.createAutoLink(matchingTextNodes, matches[matchIndex].href); } - return firstChild; + return linkCreated; }, - isToolbarShown: function () { - return this.toolbar && this.toolbar.classList.contains('medium-editor-toolbar-active'); + shouldNotLink: function (textNodes) { + var shouldNotLink = false; + for (var i = 0; i < textNodes.length && shouldNotLink === false; i++) { + // Do not link if the text node is either inside an anchor or inside span[data-auto-link] + shouldNotLink = !!Util.traverseUp(textNodes[i], function (node) { + return node.nodeName.toLowerCase() === 'a' || + (node.getAttribute && node.getAttribute('data-auto-link') === 'true'); + }); + } + return shouldNotLink; }, - showToolbar: function () { - if (this.toolbar && !this.isToolbarShown()) { - this.toolbar.classList.add('medium-editor-toolbar-active'); - if (this.onShowToolbar) { - this.onShowToolbar(); + findLinkableText: function (contenteditable) { + var linkRegExp = new RegExp(LINK_REGEXP_TEXT, 'gi'), + textContent = contenteditable.textContent, + match = null, + matches = []; + + while ((match = linkRegExp.exec(textContent)) !== null) { + var matchOk = true, + matchEnd = match.index + match[0].length; + // If the regexp detected something as a link that has text immediately preceding/following it, bail out. + matchOk = (match.index === 0 || WHITESPACE_CHARS.indexOf(textContent[match.index - 1]) !== -1) && + (matchEnd === textContent.length || WHITESPACE_CHARS.indexOf(textContent[matchEnd]) !== -1); + // If the regexp detected a bare domain that doesn't use one of our expected TLDs, bail out. + matchOk = matchOk && (match[0].indexOf('/') !== -1 || + KNOWN_TLDS_REGEXP.test(match[0].split('.').pop().split('?').shift())); + + if (matchOk) { + matches.push({ + href: match[0], + start: match.index, + end: matchEnd + }); } } + return matches; }, - hideToolbar: function () { - if (this.isToolbarShown()) { - this.toolbar.classList.remove('medium-editor-toolbar-active'); - if (this.onHideToolbar) { - this.onHideToolbar(); - } + createAutoLink: function (textNodes, href) { + href = Util.ensureUrlHasProtocol(href); + var anchor = Util.createLink(this.document, textNodes, href, this.getEditorOption('targetBlank') ? '_blank' : null), + span = this.document.createElement('span'); + span.setAttribute('data-auto-link', 'true'); + span.setAttribute('data-href', href); + anchor.insertBefore(span, anchor.firstChild); + while (anchor.childNodes.length > 1) { + span.appendChild(anchor.childNodes[1]); } + } + + }); +}()); +var FileDragging; + +(function () { + 'use strict'; + + var CLASS_DRAG_OVER = 'medium-editor-dragover'; + + function clearClassNames(element) { + var editable = Util.getContainerEditorElement(element), + existing = Array.prototype.slice.call(editable.parentElement.querySelectorAll('.' + CLASS_DRAG_OVER)); + + existing.forEach(function (el) { + el.classList.remove(CLASS_DRAG_OVER); + }); + } + + FileDragging = Extension.extend({ + name: 'fileDragging', + + allowedTypes: ['image'], + + init: function () { + Extension.prototype.init.apply(this, arguments); + + this.subscribe('editableDrag', this.handleDrag.bind(this)); + this.subscribe('editableDrop', this.handleDrop.bind(this)); }, - hideToolbarActions: function () { - this.keepToolbarAlive = false; - this.hideToolbar(); + handleDrag: function (event) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + + var target = event.target.classList ? event.target : event.target.parentElement; + + // Ensure the class gets removed from anything that had it before + clearClassNames(target); + + if (event.type === 'dragover') { + target.classList.add(CLASS_DRAG_OVER); + } }, - showToolbarActions: function () { - var self = this; - if (this.anchorForm) { - this.anchorForm.style.display = 'none'; + handleDrop: function (event) { + // Prevent file from opening in the current window + event.preventDefault(); + event.stopPropagation(); + + // IE9 does not support the File API, so prevent file from opening in the window + // but also don't try to actually get the file + if (event.dataTransfer.files) { + Array.prototype.slice.call(event.dataTransfer.files).forEach(function (file) { + if (this.isAllowedFile(file)) { + if (file.type.match('image')) { + this.insertImageFile(file); + } + } + }, this); } - this.toolbarActions.style.display = 'block'; - this.keepToolbarAlive = false; - // Using setTimeout + options.delay because: - // We will actually be displaying the toolbar, which should be controlled by options.delay - this.delay(function () { - self.showToolbar(); - }); + + // Make sure we remove our class from everything + clearClassNames(event.target); }, - saveSelection: function () { - this.savedSelection = saveSelection.call(this); + isAllowedFile: function (file) { + return this.allowedTypes.some(function (fileType) { + return !!file.type.match(fileType); + }); }, - restoreSelection: function () { - restoreSelection.call(this, this.savedSelection); + insertImageFile: function (file) { + var fileReader = new FileReader(); + fileReader.readAsDataURL(file); + + var id = 'medium-img-' + (+new Date()); + Util.insertHTMLCommand(this.document, '<img class="medium-editor-image-loading" id="' + id + '" />'); + + fileReader.onload = function () { + var img = this.document.getElementById(id); + if (img) { + img.removeAttribute('id'); + img.removeAttribute('class'); + img.src = fileReader.result; + } + }.bind(this); + } + }); +}()); + +var KeyboardCommands; +(function () { + 'use strict'; + + /*global Extension, Util */ + + KeyboardCommands = Extension.extend({ + name: 'keyboard-commands', + + /* KeyboardCommands Options */ + + /* commands: [Array] + * Array of objects describing each command and the combination of keys that will trigger it + * Required for each object: + * command [String] (argument passed to editor.execAction()) + * key [String] (keyboard character that triggers this command) + * meta [boolean] (whether the ctrl/meta key has to be active or inactive) + * shift [boolean] (whether the shift key has to be active or inactive) + * alt [boolean] (whether the alt key has to be active or inactive) + */ + commands: [ + { + command: 'bold', + key: 'B', + meta: true, + shift: false, + alt: false + }, + { + command: 'italic', + key: 'I', + meta: true, + shift: false, + alt: false + }, + { + command: 'underline', + key: 'U', + meta: true, + shift: false, + alt: false + } + ], + + init: function () { + Extension.prototype.init.apply(this, arguments); + + this.subscribe('editableKeydown', this.handleKeydown.bind(this)); + this.keys = {}; + this.commands.forEach(function (command) { + var keyCode = command.key.charCodeAt(0); + if (!this.keys[keyCode]) { + this.keys[keyCode] = []; + } + this.keys[keyCode].push(command); + }, this); }, - showAnchorForm: function (link_value) { - if (!this.anchorForm) { + handleKeydown: function (event) { + var keyCode = Util.getKeyCode(event); + if (!this.keys[keyCode]) { return; } - this.toolbarActions.style.display = 'none'; - this.saveSelection(); - this.anchorForm.style.display = 'block'; + var isMeta = Util.isMetaCtrlKey(event), + isShift = !!event.shiftKey, + isAlt = !!event.altKey; + + this.keys[keyCode].forEach(function (data) { + if (data.meta === isMeta && + data.shift === isShift && + data.alt === isAlt) { + event.preventDefault(); + event.stopPropagation(); + + // command can be false so the shortcurt is just disabled + if (false !== data.command) { + this.execAction(data.command); + } + } + }, this); + } + }); +}()); + +var FontSizeForm; +(function () { + 'use strict'; + + /*global FormExtension, Selection */ + + FontSizeForm = FormExtension.extend({ + + name: 'fontsize', + action: 'fontSize', + aria: 'increase/decrease font size', + contentDefault: '&#xB1;', // ± + contentFA: '<i class="fa fa-text-height"></i>', + + init: function () { + FormExtension.prototype.init.apply(this, arguments); + }, + + // Called when the button the toolbar is clicked + // Overrides ButtonExtension.handleClick + handleClick: function (event) { + event.preventDefault(); + event.stopPropagation(); + + if (!this.isDisplayed()) { + // Get fontsize of current selection (convert to string since IE returns this as number) + var fontSize = this.document.queryCommandValue('fontSize') + ''; + this.showForm(fontSize); + } + + return false; + }, + + // Called by medium-editor to append form to the toolbar + getForm: function () { + if (!this.form) { + this.form = this.createForm(); + } + return this.form; + }, + + // Used by medium-editor when the default toolbar is to be displayed + isDisplayed: function () { + return this.getForm().style.display === 'block'; + }, + + hideForm: function () { + this.getForm().style.display = 'none'; + this.getInput().value = ''; + }, + + showForm: function (fontSize) { + var input = this.getInput(); + + this.base.saveSelection(); + this.hideToolbarDefaultActions(); + this.getForm().style.display = 'block'; this.setToolbarPosition(); - this.keepToolbarAlive = true; - this.anchorInput.focus(); - this.anchorInput.value = link_value || ''; + + input.value = fontSize || ''; + input.focus(); }, - bindAnchorForm: function () { - if (!this.anchorForm) { - return this; + // Called by core when tearing down medium-editor (destroy) + destroy: function () { + if (!this.form) { + return false; } - var linkCancel = this.anchorForm.querySelector('a.medium-editor-toobar-close'), - linkSave = this.anchorForm.querySelector('a.medium-editor-toobar-save'), - self = this; + if (this.form.parentNode) { + this.form.parentNode.removeChild(this.form); + } - this.on(this.anchorForm, 'click', function (e) { - e.stopPropagation(); - self.keepToolbarAlive = true; + delete this.form; + }, + + // core methods + + doFormSave: function () { + this.base.restoreSelection(); + this.base.checkSelection(); + }, + + doFormCancel: function () { + this.base.restoreSelection(); + this.clearFontSize(); + this.base.checkSelection(); + }, + + // form creation and event handling + createForm: function () { + var doc = this.document, + form = doc.createElement('div'), + input = doc.createElement('input'), + close = doc.createElement('a'), + save = doc.createElement('a'); + + // Font Size Form (div) + form.className = 'medium-editor-toolbar-form'; + form.id = 'medium-editor-toolbar-form-fontsize-' + this.getEditorId(); + + // Handle clicks on the form itself + this.on(form, 'click', this.handleFormClick.bind(this)); + + // Add font size slider + input.setAttribute('type', 'range'); + input.setAttribute('min', '1'); + input.setAttribute('max', '7'); + input.className = 'medium-editor-toolbar-input'; + form.appendChild(input); + + // Handle typing in the textbox + this.on(input, 'change', this.handleSliderChange.bind(this)); + + // Add save buton + save.setAttribute('href', '#'); + save.className = 'medium-editor-toobar-save'; + save.innerHTML = this.getEditorOption('buttonLabels') === 'fontawesome' ? + '<i class="fa fa-check"></i>' : + '&#10003;'; + form.appendChild(save); + + // Handle save button clicks (capture) + this.on(save, 'click', this.handleSaveClick.bind(this), true); + + // Add close button + close.setAttribute('href', '#'); + close.className = 'medium-editor-toobar-close'; + close.innerHTML = this.getEditorOption('buttonLabels') === 'fontawesome' ? + '<i class="fa fa-times"></i>' : + '&times;'; + form.appendChild(close); + + // Handle close button clicks + this.on(close, 'click', this.handleCloseClick.bind(this)); + + return form; + }, + + getInput: function () { + return this.getForm().querySelector('input.medium-editor-toolbar-input'); + }, + + clearFontSize: function () { + Selection.getSelectedElements(this.document).forEach(function (el) { + if (el.nodeName.toLowerCase() === 'font' && el.hasAttribute('size')) { + el.removeAttribute('size'); + } }); + }, - this.on(this.anchorInput, 'keyup', function (e) { - var button = null, - target; + handleSliderChange: function () { + var size = this.getInput().value; + if (size === '4') { + this.clearFontSize(); + } else { + this.execAction('fontSize', { size: size }); + } + }, - if (e.keyCode === 13) { - e.preventDefault(); - if (self.options.anchorTarget && self.anchorTarget.checked) { - target = "_blank"; + handleFormClick: function (event) { + // make sure not to hide form when clicking inside the form + event.stopPropagation(); + }, + + handleSaveClick: function (event) { + // Clicking Save -> create the font size + event.preventDefault(); + this.doFormSave(); + }, + + handleCloseClick: function (event) { + // Click Close -> close the form + event.preventDefault(); + this.doFormCancel(); + } + }); +}()); +var PasteHandler; + +(function () { + 'use strict'; + /*jslint regexp: true*/ + /* + jslint does not allow character negation, because the negation + will not match any unicode characters. In the regexes in this + block, negation is used specifically to match the end of an html + tag, and in fact unicode characters *should* be allowed. + */ + function createReplacements() { + return [ + + // replace two bogus tags that begin pastes from google docs + [new RegExp(/<[^>]*docs-internal-guid[^>]*>/gi), ''], + [new RegExp(/<\/b>(<br[^>]*>)?$/gi), ''], + + // un-html spaces and newlines inserted by OS X + [new RegExp(/<span class="Apple-converted-space">\s+<\/span>/g), ' '], + [new RegExp(/<br class="Apple-interchange-newline">/g), '<br>'], + + // replace google docs italics+bold with a span to be replaced once the html is inserted + [new RegExp(/<span[^>]*(font-style:italic;font-weight:bold|font-weight:bold;font-style:italic)[^>]*>/gi), '<span class="replace-with italic bold">'], + + // replace google docs italics with a span to be replaced once the html is inserted + [new RegExp(/<span[^>]*font-style:italic[^>]*>/gi), '<span class="replace-with italic">'], + + //[replace google docs bolds with a span to be replaced once the html is inserted + [new RegExp(/<span[^>]*font-weight:bold[^>]*>/gi), '<span class="replace-with bold">'], + + // replace manually entered b/i/a tags with real ones + [new RegExp(/&lt;(\/?)(i|b|a)&gt;/gi), '<$1$2>'], + + // replace manually a tags with real ones, converting smart-quotes from google docs + [new RegExp(/&lt;a(?:(?!href).)+href=(?:&quot;|&rdquo;|&ldquo;|"|“|”)(((?!&quot;|&rdquo;|&ldquo;|"|“|”).)*)(?:&quot;|&rdquo;|&ldquo;|"|“|”)(?:(?!&gt;).)*&gt;/gi), '<a href="$1">'], + + // Newlines between paragraphs in html have no syntactic value, + // but then have a tendency to accidentally become additional paragraphs down the line + [new RegExp(/<\/p>\n+/gi), '</p>'], + [new RegExp(/\n+<p/gi), '<p'], + + // Microsoft Word makes these odd tags, like <o:p></o:p> + [new RegExp(/<\/?o:[a-z]*>/gi), ''] + ]; + } + /*jslint regexp: false*/ + + PasteHandler = Extension.extend({ + /* Paste Options */ + + /* forcePlainText: [boolean] + * Forces pasting as plain text. + */ + forcePlainText: true, + + /* cleanPastedHTML: [boolean] + * cleans pasted content from different sources, like google docs etc. + */ + cleanPastedHTML: false, + + /* cleanReplacements: [Array] + * custom pairs (2 element arrays) of RegExp and replacement text to use during paste when + * __forcePlainText__ or __cleanPastedHTML__ are `true` OR when calling `cleanPaste(text)` helper method. + */ + cleanReplacements: [], + + /* cleanAttrs:: [Array] + * list of element attributes to remove during paste when __cleanPastedHTML__ is `true` or when + * calling `cleanPaste(text)` or `pasteHTML(html, options)` helper methods. + */ + cleanAttrs: ['class', 'style', 'dir'], + + /* cleanTags: [Array] + * list of element tag names to remove during paste when __cleanPastedHTML__ is `true` or when + * calling `cleanPaste(text)` or `pasteHTML(html, options)` helper methods. + */ + cleanTags: ['meta'], + + init: function () { + Extension.prototype.init.apply(this, arguments); + + if (this.forcePlainText || this.cleanPastedHTML) { + this.subscribe('editablePaste', this.handlePaste.bind(this)); + } + }, + + handlePaste: function (event, element) { + var paragraphs, + html = '', + p, + dataFormatHTML = 'text/html', + dataFormatPlain = 'text/plain', + pastedHTML, + pastedPlain; + + if (this.window.clipboardData && event.clipboardData === undefined) { + event.clipboardData = this.window.clipboardData; + // If window.clipboardData exists, but event.clipboardData doesn't exist, + // we're probably in IE. IE only has two possibilities for clipboard + // data format: 'Text' and 'URL'. + // + // Of the two, we want 'Text': + dataFormatHTML = 'Text'; + dataFormatPlain = 'Text'; + } + + if (event.clipboardData && + event.clipboardData.getData && + !event.defaultPrevented) { + event.preventDefault(); + + pastedHTML = event.clipboardData.getData(dataFormatHTML); + pastedPlain = event.clipboardData.getData(dataFormatPlain); + + if (this.cleanPastedHTML && pastedHTML) { + return this.cleanPaste(pastedHTML); + } + + if (!(this.getEditorOption('disableReturn') || element.getAttribute('data-disable-return'))) { + paragraphs = pastedPlain.split(/[\r\n]+/g); + // If there are no \r\n in data, don't wrap in <p> + if (paragraphs.length > 1) { + for (p = 0; p < paragraphs.length; p += 1) { + if (paragraphs[p] !== '') { + html += '<p>' + Util.htmlEntities(paragraphs[p]) + '</p>'; + } + } } else { - target = "_self"; + html = Util.htmlEntities(paragraphs[0]); } + } else { + html = Util.htmlEntities(pastedPlain); + } + Util.insertHTMLCommand(this.document, html); + } + }, - if (self.options.anchorButton && self.anchorButton.checked) { - button = self.options.anchorButtonClass; - } + cleanPaste: function (text) { + var i, elList, workEl, + el = Selection.getSelectionElement(this.window), + multiline = /<p|<br|<div/.test(text), + replacements = createReplacements().concat(this.cleanReplacements || []); - self.createLink(this, target, button); - } else if (e.keyCode === 27) { - e.preventDefault(); - self.showToolbarActions(); - restoreSelection.call(self, self.savedSelection); + for (i = 0; i < replacements.length; i += 1) { + text = text.replace(replacements[i][0], replacements[i][1]); + } + + if (!multiline) { + return this.pasteHTML(text); + } + + // double br's aren't converted to p tags, but we want paragraphs. + elList = text.split('<br><br>'); + + this.pasteHTML('<p>' + elList.join('</p><p>') + '</p>'); + + try { + this.document.execCommand('insertText', false, '\n'); + } catch (ignore) { } + + // block element cleanup + elList = el.querySelectorAll('a,p,div,br'); + for (i = 0; i < elList.length; i += 1) { + workEl = elList[i]; + + // Microsoft Word replaces some spaces with newlines. + // While newlines between block elements are meaningless, newlines within + // elements are sometimes actually spaces. + workEl.innerHTML = workEl.innerHTML.replace(/\n/gi, ' '); + + switch (workEl.nodeName.toLowerCase()) { + case 'p': + case 'div': + this.filterCommonBlocks(workEl); + break; + case 'br': + this.filterLineBreak(workEl); + break; } + } + }, + + pasteHTML: function (html, options) { + options = Util.defaults({}, options, { + cleanAttrs: this.cleanAttrs, + cleanTags: this.cleanTags }); - this.on(linkSave, 'click', function (e) { - var button = null, - target; - e.preventDefault(); - if (self.options.anchorTarget && self.anchorTarget.checked) { - target = "_blank"; + var elList, workEl, i, fragmentBody, pasteBlock = this.document.createDocumentFragment(); + + pasteBlock.appendChild(this.document.createElement('body')); + + fragmentBody = pasteBlock.querySelector('body'); + fragmentBody.innerHTML = html; + + this.cleanupSpans(fragmentBody); + + elList = fragmentBody.querySelectorAll('*'); + + for (i = 0; i < elList.length; i += 1) { + workEl = elList[i]; + + if ('a' === workEl.nodeName.toLowerCase() && this.getEditorOption('targetBlank')) { + Util.setTargetBlank(workEl); + } + + Util.cleanupAttrs(workEl, options.cleanAttrs); + Util.cleanupTags(workEl, options.cleanTags); + } + + Util.insertHTMLCommand(this.document, fragmentBody.innerHTML.replace(/&nbsp;/g, ' ')); + }, + + isCommonBlock: function (el) { + return (el && (el.nodeName.toLowerCase() === 'p' || el.nodeName.toLowerCase() === 'div')); + }, + + filterCommonBlocks: function (el) { + if (/^\s*$/.test(el.textContent) && el.parentNode) { + el.parentNode.removeChild(el); + } + }, + + filterLineBreak: function (el) { + if (this.isCommonBlock(el.previousElementSibling)) { + // remove stray br's following common block elements + this.removeWithParent(el); + } else if (this.isCommonBlock(el.parentNode) && (el.parentNode.firstChild === el || el.parentNode.lastChild === el)) { + // remove br's just inside open or close tags of a div/p + this.removeWithParent(el); + } else if (el.parentNode && el.parentNode.childElementCount === 1 && el.parentNode.textContent === '') { + // and br's that are the only child of elements other than div/p + this.removeWithParent(el); + } + }, + + // remove an element, including its parent, if it is the only element within its parent + removeWithParent: function (el) { + if (el && el.parentNode) { + if (el.parentNode.parentNode && el.parentNode.childElementCount === 1) { + el.parentNode.parentNode.removeChild(el.parentNode); } else { - target = "_self"; + el.parentNode.removeChild(el); } + } + }, - if (self.options.anchorButton && self.anchorButton.checked) { - button = self.options.anchorButtonClass; + cleanupSpans: function (containerEl) { + var i, + el, + newEl, + spans = containerEl.querySelectorAll('.replace-with'), + isCEF = function (el) { + return (el && el.nodeName !== '#text' && el.getAttribute('contenteditable') === 'false'); + }; + + for (i = 0; i < spans.length; i += 1) { + el = spans[i]; + newEl = this.document.createElement(el.classList.contains('bold') ? 'b' : 'i'); + + if (el.classList.contains('bold') && el.classList.contains('italic')) { + // add an i tag as well if this has both italics and bold + newEl.innerHTML = '<i>' + el.innerHTML + '</i>'; + } else { + newEl.innerHTML = el.innerHTML; } + el.parentNode.replaceChild(newEl, el); + } - self.createLink(self.anchorInput, target, button); - }, true); + spans = containerEl.querySelectorAll('span'); + for (i = 0; i < spans.length; i += 1) { + el = spans[i]; - this.on(this.anchorInput, 'click', function (e) { - // make sure not to hide form when cliking into the input - e.stopPropagation(); - self.keepToolbarAlive = true; - }); + // bail if span is in contenteditable = false + if (Util.traverseUp(el, isCEF)) { + return false; + } - // Hide the anchor form when focusing outside of it. - this.on(this.options.ownerDocument.body, 'click', function (e) { - if (e.target !== self.anchorForm && !isDescendant(self.anchorForm, e.target) && !isDescendant(self.toolbarActions, e.target)) { - self.keepToolbarAlive = false; - self.checkSelection(); + // remove empty spans, replace others with their contents + Util.unwrap(el, this.document); + } + } + }); +}()); + +var Placeholder; + +(function () { + 'use strict'; + + /*global Extension */ + + Placeholder = Extension.extend({ + name: 'placeholder', + + /* Placeholder Options */ + + /* text: [string] + * Text to display in the placeholder + */ + text: 'Type your text', + + /* hideOnClick: [boolean] + * Should we hide the placeholder on click (true) or when user starts typing (false) + */ + hideOnClick: true, + + init: function () { + Extension.prototype.init.apply(this, arguments); + + this.initPlaceholders(); + this.attachEventHandlers(); + }, + + initPlaceholders: function () { + this.getEditorElements().forEach(function (el) { + if (!el.getAttribute('data-placeholder')) { + el.setAttribute('data-placeholder', this.text); } - }, true); - this.on(this.options.ownerDocument.body, 'focus', function (e) { - if (e.target !== self.anchorForm && !isDescendant(self.anchorForm, e.target) && !isDescendant(self.toolbarActions, e.target)) { - self.keepToolbarAlive = false; - self.checkSelection(); + this.updatePlaceholder(el); + }, this); + }, + + destroy: function () { + this.getEditorElements().forEach(function (el) { + if (el.getAttribute('data-placeholder') === this.text) { + el.removeAttribute('data-placeholder'); } - }, true); + }, this); + }, - this.on(linkCancel, 'click', function (e) { - e.preventDefault(); - self.showToolbarActions(); - restoreSelection.call(self, self.savedSelection); - }); - return this; + showPlaceholder: function (el) { + if (el) { + el.classList.add('medium-editor-placeholder'); + } }, - hideAnchorPreview: function () { - this.anchorPreview.classList.remove('medium-editor-anchor-preview-active'); + hidePlaceholder: function (el) { + if (el) { + el.classList.remove('medium-editor-placeholder'); + } }, - // TODO: break method - showAnchorPreview: function (anchorEl) { - if (this.anchorPreview.classList.contains('medium-editor-anchor-preview-active') - || anchorEl.getAttribute('data-disable-preview')) { - return true; + updatePlaceholder: function (el) { + // if one of these element ('img, blockquote, ul, ol') are found inside the given element, we won't display the placeholder + if (!(el.querySelector('img, blockquote, ul, ol')) && el.textContent.replace(/^\s+|\s+$/g, '') === '') { + return this.showPlaceholder(el); } - var self = this, - buttonHeight = 40, - boundary = anchorEl.getBoundingClientRect(), - middleBoundary = (boundary.left + boundary.right) / 2, - halfOffsetWidth, - defaultLeft; + this.hidePlaceholder(el); + }, - self.anchorPreview.querySelector('i').textContent = anchorEl.href; - halfOffsetWidth = self.anchorPreview.offsetWidth / 2; - defaultLeft = self.options.diffLeft - halfOffsetWidth; + attachEventHandlers: function () { + // Custom events + this.subscribe('blur', this.handleExternalInteraction.bind(this)); - self.observeAnchorPreview(anchorEl); + // Check placeholder on blur + this.subscribe('editableBlur', this.handleBlur.bind(this)); - self.anchorPreview.classList.add('medium-toolbar-arrow-over'); - self.anchorPreview.classList.remove('medium-toolbar-arrow-under'); - self.anchorPreview.style.top = Math.round(buttonHeight + boundary.bottom - self.options.diffTop + this.options.contentWindow.pageYOffset - self.anchorPreview.offsetHeight) + 'px'; - if (middleBoundary < halfOffsetWidth) { - self.anchorPreview.style.left = defaultLeft + halfOffsetWidth + 'px'; - } else if ((this.options.contentWindow.innerWidth - middleBoundary) < halfOffsetWidth) { - self.anchorPreview.style.left = this.options.contentWindow.innerWidth + defaultLeft - halfOffsetWidth + 'px'; + // if we don't want the placeholder to be removed on click but when user start typing + if (this.hideOnClick) { + this.subscribe('editableClick', this.handleHidePlaceholderEvent.bind(this)); } else { - self.anchorPreview.style.left = defaultLeft + middleBoundary + 'px'; + this.subscribe('editableKeyup', this.handleBlur.bind(this)); } - if (this.anchorPreview && !this.anchorPreview.classList.contains('medium-editor-anchor-preview-active')) { - this.anchorPreview.classList.add('medium-editor-anchor-preview-active'); - } + // Events where we always hide the placeholder + this.subscribe('editableKeypress', this.handleHidePlaceholderEvent.bind(this)); + this.subscribe('editablePaste', this.handleHidePlaceholderEvent.bind(this)); + }, - return this; + handleHidePlaceholderEvent: function (event, element) { + // Events where we hide the placeholder + this.hidePlaceholder(element); }, - // TODO: break method - observeAnchorPreview: function (anchorEl) { - var self = this, - lastOver = (new Date()).getTime(), - over = true, - stamp = function () { - lastOver = (new Date()).getTime(); - over = true; - }, - unstamp = function (e) { - if (!e.relatedTarget || !/anchor-preview/.test(e.relatedTarget.className)) { - over = false; - } - }, - interval_timer = setInterval(function () { - if (over) { - return true; - } - var durr = (new Date()).getTime() - lastOver; - if (durr > self.options.anchorPreviewHideDelay) { - // hide the preview 1/2 second after mouse leaves the link - self.hideAnchorPreview(); + handleBlur: function (event, element) { + // Update placeholder for element that lost focus + this.updatePlaceholder(element); + }, - // cleanup - clearInterval(interval_timer); - self.off(self.anchorPreview, 'mouseover', stamp); - self.off(self.anchorPreview, 'mouseout', unstamp); - self.off(anchorEl, 'mouseover', stamp); - self.off(anchorEl, 'mouseout', unstamp); + handleExternalInteraction: function () { + // Update all placeholders + this.initPlaceholders(); + } + }); +}()); - } - }, 200); +var Toolbar; +(function () { + 'use strict'; - this.on(self.anchorPreview, 'mouseover', stamp); - this.on(self.anchorPreview, 'mouseout', unstamp); - this.on(anchorEl, 'mouseover', stamp); - this.on(anchorEl, 'mouseout', unstamp); - }, + /*global Util, Selection, Extension */ - createAnchorPreview: function () { - var self = this, - anchorPreview = this.options.ownerDocument.createElement('div'); + Toolbar = Extension.extend({ + name: 'toolbar', - anchorPreview.id = 'medium-editor-anchor-preview-' + this.id; - anchorPreview.className = 'medium-editor-anchor-preview'; - anchorPreview.innerHTML = this.anchorPreviewTemplate(); - this.options.elementsContainer.appendChild(anchorPreview); + /* Toolbar Options */ - this.on(anchorPreview, 'click', function () { - self.anchorPreviewClickHandler(); - }); + /* align: ['left'|'center'|'right'] + * When the __static__ option is true, this aligns the static toolbar + * relative to the medium-editor element. + */ + align: 'center', - return anchorPreview; + /* allowMultiParagraphSelection: [boolean] + * enables/disables whether the toolbar should be displayed when + * selecting multiple paragraphs/block elements + */ + allowMultiParagraphSelection: true, + + /* buttons: [Array] + * the names of the set of buttons to display on the toolbar. + */ + buttons: ['bold', 'italic', 'underline', 'anchor', 'h2', 'h3', 'quote'], + + /* diffLeft: [Number] + * value in pixels to be added to the X axis positioning of the toolbar. + */ + diffLeft: 0, + + /* diffTop: [Number] + * value in pixels to be added to the Y axis positioning of the toolbar. + */ + diffTop: -10, + + /* firstButtonClass: [string] + * CSS class added to the first button in the toolbar. + */ + firstButtonClass: 'medium-editor-button-first', + + /* lastButtonClass: [string] + * CSS class added to the last button in the toolbar. + */ + lastButtonClass: 'medium-editor-button-last', + + /* standardizeSelectionStart: [boolean] + * enables/disables standardizing how the beginning of a range is decided + * between browsers whenever the selected text is analyzed for updating toolbar buttons status. + */ + standardizeSelectionStart: false, + + /* static: [boolean] + * enable/disable the toolbar always displaying in the same location + * relative to the medium-editor element. + */ + static: false, + + /* sticky: [boolean] + * When the __static__ option is true, this enables/disables the toolbar + * "sticking" to the viewport and staying visible on the screen while + * the page scrolls. + */ + sticky: false, + + /* updateOnEmptySelection: [boolean] + * When the __static__ option is true, this enables/disables updating + * the state of the toolbar buttons even when the selection is collapsed + * (there is no selection, just a cursor). + */ + updateOnEmptySelection: false, + + init: function () { + Extension.prototype.init.apply(this, arguments); + + this.initThrottledMethods(); + this.getEditorOption('elementsContainer').appendChild(this.getToolbarElement()); }, - anchorPreviewTemplate: function () { - return '<div class="medium-editor-toolbar-anchor-preview" id="medium-editor-toolbar-anchor-preview">' + - ' <i class="medium-editor-toolbar-anchor-preview-inner"></i>' + - '</div>'; + // Helper method to execute method for every extension, but ignoring the toolbar extension + forEachExtension: function (iterator, context) { + return this.base.extensions.forEach(function (command) { + if (command === this) { + return; + } + return iterator.apply(context || this, arguments); + }, this); }, - anchorPreviewClickHandler: function (e) { - if (!this.options.disableAnchorForm && this.activeAnchor) { + // Toolbar creation/deletion - var self = this, - range = this.options.ownerDocument.createRange(), - sel = this.options.contentWindow.getSelection(); + createToolbar: function () { + var toolbar = this.document.createElement('div'); - range.selectNodeContents(self.activeAnchor); - sel.removeAllRanges(); - sel.addRange(range); - // Using setTimeout + options.delay because: - // We may actually be displaying the anchor preview, which should be controlled by options.delay - this.delay(function () { - if (self.activeAnchor) { - self.showAnchorForm(self.activeAnchor.href); - } - self.keepToolbarAlive = false; - }); + toolbar.id = 'medium-editor-toolbar-' + this.getEditorId(); + toolbar.className = 'medium-editor-toolbar'; + if (this.static) { + toolbar.className += ' static-toolbar'; + } else { + toolbar.className += ' medium-editor-stalker-toolbar'; } - this.hideAnchorPreview(); + toolbar.appendChild(this.createToolbarButtons()); + + // Add any forms that extensions may have + this.forEachExtension(function (extension) { + if (extension.hasForm) { + toolbar.appendChild(extension.getForm()); + } + }); + + this.attachEventHandlers(); + + return toolbar; }, - editorAnchorObserver: function (e) { - var self = this, - overAnchor = true, - leaveAnchor = function () { - // mark the anchor as no longer hovered, and stop listening - overAnchor = false; - self.off(self.activeAnchor, 'mouseout', leaveAnchor); - }; + createToolbarButtons: function () { + var ul = this.document.createElement('ul'), + li, + btn, + buttons, + extension, + buttonName, + buttonOpts; - if (e.target && e.target.tagName.toLowerCase() === 'a') { + ul.id = 'medium-editor-toolbar-actions' + this.getEditorId(); + ul.className = 'medium-editor-toolbar-actions'; + ul.style.display = 'block'; - // Detect empty href attributes - // The browser will make href="" or href="#top" - // into absolute urls when accessed as e.targed.href, so check the html - if (!/href=["']\S+["']/.test(e.target.outerHTML) || /href=["']#\S+["']/.test(e.target.outerHTML)) { - return true; + this.buttons.forEach(function (button) { + if (typeof button === 'string') { + buttonName = button; + buttonOpts = null; + } else { + buttonName = button.name; + buttonOpts = button; } - // only show when hovering on anchors - if (this.isToolbarShown()) { - // only show when toolbar is not present - return true; - } - this.activeAnchor = e.target; - this.on(this.activeAnchor, 'mouseout', leaveAnchor); - // Using setTimeout + options.delay because: - // - We're going to show the anchor preview according to the configured delay - // if the mouse has not left the anchor tag in that time - this.delay(function () { - if (overAnchor) { - self.showAnchorPreview(e.target); + // If the button already exists as an extension, it'll be returned + // othwerise it'll create the default built-in button + extension = this.base.addBuiltInExtension(buttonName, buttonOpts); + + if (extension && typeof extension.getButton === 'function') { + btn = extension.getButton(this.base); + li = this.document.createElement('li'); + if (Util.isElement(btn)) { + li.appendChild(btn); + } else { + li.innerHTML = btn; } - }); + ul.appendChild(li); + } + }, this); + + buttons = ul.querySelectorAll('button'); + if (buttons.length > 0) { + buttons[0].classList.add(this.firstButtonClass); + buttons[buttons.length - 1].classList.add(this.lastButtonClass); } + + return ul; }, - bindAnchorPreview: function (index) { - var i, self = this; - this.editorAnchorObserverWrapper = function (e) { - self.editorAnchorObserver(e); - }; - for (i = 0; i < this.elements.length; i += 1) { - this.on(this.elements[i], 'mouseover', this.editorAnchorObserverWrapper); + destroy: function () { + if (this.toolbar) { + if (this.toolbar.parentNode) { + this.toolbar.parentNode.removeChild(this.toolbar); + } + delete this.toolbar; } - return this; }, - checkLinkFormat: function (value) { - var re = /^(https?|ftps?|rtmpt?):\/\/|mailto:/; - return (re.test(value) ? '' : 'http://') + value; + // Toolbar accessors + + getToolbarElement: function () { + if (!this.toolbar) { + this.toolbar = this.createToolbar(); + } + + return this.toolbar; }, - setTargetBlank: function (el) { - var i; - el = el || getSelectionStart.call(this); - if (el.tagName.toLowerCase() === 'a') { - el.target = '_blank'; - } else { - el = el.getElementsByTagName('a'); + getToolbarActionsElement: function () { + return this.getToolbarElement().querySelector('.medium-editor-toolbar-actions'); + }, - for (i = 0; i < el.length; i += 1) { - el[i].target = '_blank'; + // Toolbar event handlers + + initThrottledMethods: function () { + // throttledPositionToolbar is throttled because: + // - It will be called when the browser is resizing, which can fire many times very quickly + // - For some event (like resize) a slight lag in UI responsiveness is OK and provides performance benefits + this.throttledPositionToolbar = Util.throttle(function () { + if (this.base.isActive) { + this.positionToolbarIfShown(); } + }.bind(this)); + }, + + attachEventHandlers: function () { + // MediumEditor custom events for when user beings and ends interaction with a contenteditable and its elements + this.subscribe('blur', this.handleBlur.bind(this)); + this.subscribe('focus', this.handleFocus.bind(this)); + + // Updating the state of the toolbar as things change + this.subscribe('editableClick', this.handleEditableClick.bind(this)); + this.subscribe('editableKeyup', this.handleEditableKeyup.bind(this)); + + // Handle mouseup on document for updating the selection in the toolbar + this.on(this.document.documentElement, 'mouseup', this.handleDocumentMouseup.bind(this)); + + // Add a scroll event for sticky toolbar + if (this.static && this.sticky) { + // On scroll (capture), re-position the toolbar + this.on(this.window, 'scroll', this.handleWindowScroll.bind(this), true); } + + // On resize, re-position the toolbar + this.on(this.window, 'resize', this.handleWindowResize.bind(this)); }, - setButtonClass: function (buttonClass) { - var el = getSelectionStart.call(this), - classes = buttonClass.split(' '), - i, - j; - if (el.tagName.toLowerCase() === 'a') { - for (j = 0; j < classes.length; j += 1) { - el.classList.add(classes[j]); + handleWindowScroll: function () { + this.positionToolbarIfShown(); + }, + + handleWindowResize: function () { + this.throttledPositionToolbar(); + }, + + handleDocumentMouseup: function (event) { + // Do not trigger checkState when mouseup fires over the toolbar + if (event && + event.target && + Util.isDescendant(this.getToolbarElement(), event.target)) { + return false; + } + this.checkState(); + }, + + handleEditableClick: function () { + // Delay the call to checkState to handle bug where selection is empty + // immediately after clicking inside a pre-existing selection + setTimeout(function () { + this.checkState(); + }.bind(this), 0); + }, + + handleEditableKeyup: function () { + this.checkState(); + }, + + handleBlur: function () { + // Kill any previously delayed calls to hide the toolbar + clearTimeout(this.hideTimeout); + + // Blur may fire even if we have a selection, so we want to prevent any delayed showToolbar + // calls from happening in this specific case + clearTimeout(this.delayShowTimeout); + + // Delay the call to hideToolbar to handle bug with multiple editors on the page at once + this.hideTimeout = setTimeout(function () { + this.hideToolbar(); + }.bind(this), 1); + }, + + handleFocus: function () { + this.checkState(); + }, + + // Hiding/showing toolbar + + isDisplayed: function () { + return this.getToolbarElement().classList.contains('medium-editor-toolbar-active'); + }, + + showToolbar: function () { + clearTimeout(this.hideTimeout); + if (!this.isDisplayed()) { + this.getToolbarElement().classList.add('medium-editor-toolbar-active'); + this.trigger('showToolbar', {}, this.base.getFocusedElement()); + } + }, + + hideToolbar: function () { + if (this.isDisplayed()) { + this.getToolbarElement().classList.remove('medium-editor-toolbar-active'); + this.trigger('hideToolbar', {}, this.base.getFocusedElement()); + } + }, + + isToolbarDefaultActionsDisplayed: function () { + return this.getToolbarActionsElement().style.display === 'block'; + }, + + hideToolbarDefaultActions: function () { + if (this.isToolbarDefaultActionsDisplayed()) { + this.getToolbarActionsElement().style.display = 'none'; + } + }, + + showToolbarDefaultActions: function () { + this.hideExtensionForms(); + + if (!this.isToolbarDefaultActionsDisplayed()) { + this.getToolbarActionsElement().style.display = 'block'; + } + + // Using setTimeout + options.delay because: + // We will actually be displaying the toolbar, which should be controlled by options.delay + this.delayShowTimeout = this.base.delay(function () { + this.showToolbar(); + }.bind(this)); + }, + + hideExtensionForms: function () { + // Hide all extension forms + this.forEachExtension(function (extension) { + if (extension.hasForm && extension.isDisplayed()) { + extension.hideForm(); } - } else { - el = el.getElementsByTagName('a'); - for (i = 0; i < el.length; i += 1) { - for (j = 0; j < classes.length; j += 1) { - el[i].classList.add(classes[j]); + }); + }, + + // Responding to changes in user selection + + // Checks for existance of multiple block elements in the current selection + multipleBlockElementsSelected: function () { + var regexEmptyHTMLTags = /<[^\/>][^>]*><\/[^>]+>/gim, // http://stackoverflow.com/questions/3129738/remove-empty-tags-using-regex + regexBlockElements = new RegExp('<(' + Util.blockContainerElementNames.join('|') + ')[^>]*>', 'g'), + selectionHTML = Selection.getSelectionHtml(this.document).replace(regexEmptyHTMLTags, ''), // Filter out empty blocks from selection + hasMultiParagraphs = selectionHTML.match(regexBlockElements); // Find how many block elements are within the html + + return !!hasMultiParagraphs && hasMultiParagraphs.length > 1; + }, + + modifySelection: function () { + var selection = this.window.getSelection(), + selectionRange = selection.getRangeAt(0); + + /* + * In firefox, there are cases (ie doubleclick of a word) where the selectionRange start + * will be at the very end of an element. In other browsers, the selectionRange start + * would instead be at the very beginning of an element that actually has content. + * example: + * <span>foo</span><span>bar</span> + * + * If the text 'bar' is selected, most browsers will have the selectionRange start at the beginning + * of the 'bar' span. However, there are cases where firefox will have the selectionRange start + * at the end of the 'foo' span. The contenteditable behavior will be ok, but if there are any + * properties on the 'bar' span, they won't be reflected accurately in the toolbar + * (ie 'Bold' button wouldn't be active) + * + * So, for cases where the selectionRange start is at the end of an element/node, find the next + * adjacent text node that actually has content in it, and move the selectionRange start there. + */ + if (this.standardizeSelectionStart && + selectionRange.startContainer.nodeValue && + (selectionRange.startOffset === selectionRange.startContainer.nodeValue.length)) { + var adjacentNode = Util.findAdjacentTextNodeWithContent(Selection.getSelectionElement(this.window), selectionRange.startContainer, this.document); + if (adjacentNode) { + var offset = 0; + while (adjacentNode.nodeValue.substr(offset, 1).trim().length === 0) { + offset = offset + 1; } + selectionRange = Selection.select(this.document, adjacentNode, offset, + selectionRange.endContainer, selectionRange.offset); } } }, - createLink: function (input, target, buttonClass) { - var i, event; - - if (input.value.trim().length === 0) { - this.hideToolbarActions(); + checkState: function () { + if (this.base.preventSelectionUpdates) { return; } - restoreSelection.call(this, this.savedSelection); + // If no editable has focus OR selection is inside contenteditable = false + // hide toolbar + if (!this.base.getFocusedElement() || + Selection.selectionInContentEditableFalse(this.window)) { + return this.hideToolbar(); + } - if (this.options.checkLinkFormat) { - input.value = this.checkLinkFormat(input.value); + // If there's no selection element, selection element doesn't belong to this editor + // or toolbar is disabled for this selection element + // hide toolbar + var selectionElement = Selection.getSelectionElement(this.window); + if (!selectionElement || + this.getEditorElements().indexOf(selectionElement) === -1 || + selectionElement.getAttribute('data-disable-toolbar')) { + return this.hideToolbar(); } - this.options.ownerDocument.execCommand('createLink', false, input.value); + // Now we know there's a focused editable with a selection - if (this.options.targetBlank || target === "_blank") { - this.setTargetBlank(); + // If the updateOnEmptySelection option is true, show the toolbar + if (this.updateOnEmptySelection && this.static) { + return this.showAndUpdateToolbar(); } - if (buttonClass) { - this.setButtonClass(buttonClass); + // If we don't have a 'valid' selection -> hide toolbar + if (this.window.getSelection().toString().trim() === '' || + (this.allowMultiParagraphSelection === false && this.multipleBlockElementsSelected())) { + return this.hideToolbar(); } - if (this.options.targetBlank || target === "_blank" || buttonClass) { - event = this.options.ownerDocument.createEvent("HTMLEvents"); - event.initEvent("input", true, true, this.options.contentWindow); - for (i = 0; i < this.elements.length; i += 1) { - this.elements[i].dispatchEvent(event); + this.showAndUpdateToolbar(); + }, + + // Updating the toolbar + + showAndUpdateToolbar: function () { + this.modifySelection(); + this.setToolbarButtonStates(); + this.trigger('positionToolbar', {}, this.base.getFocusedElement()); + this.showToolbarDefaultActions(); + this.setToolbarPosition(); + }, + + setToolbarButtonStates: function () { + this.forEachExtension(function (extension) { + if (typeof extension.isActive === 'function' && + typeof extension.setInactive === 'function') { + extension.setInactive(); } + }); + + this.checkActiveButtons(); + }, + + checkActiveButtons: function () { + var manualStateChecks = [], + queryState = null, + selectionRange = Selection.getSelectionRange(this.document), + parentNode, + updateExtensionState = function (extension) { + if (typeof extension.checkState === 'function') { + extension.checkState(parentNode); + } else if (typeof extension.isActive === 'function' && + typeof extension.isAlreadyApplied === 'function' && + typeof extension.setActive === 'function') { + if (!extension.isActive() && extension.isAlreadyApplied(parentNode)) { + extension.setActive(); + } + } + }; + + if (!selectionRange) { + return; } - this.checkSelection(); - this.showToolbarActions(); - input.value = ''; + // Loop through all extensions + this.forEachExtension(function (extension) { + // For those extensions where we can use document.queryCommandState(), do so + if (typeof extension.queryCommandState === 'function') { + queryState = extension.queryCommandState(); + // If queryCommandState returns a valid value, we can trust the browser + // and don't need to do our manual checks + if (queryState !== null) { + if (queryState && typeof extension.setActive === 'function') { + extension.setActive(); + } + return; + } + } + // We can't use queryCommandState for this extension, so add to manualStateChecks + manualStateChecks.push(extension); + }); + + parentNode = Selection.getSelectedParentElement(selectionRange); + + // Make sure the selection parent isn't outside of the contenteditable + if (!this.getEditorElements().some(function (element) { + return Util.isDescendant(element, parentNode, true); + })) { + return; + } + + // Climb up the DOM and do manual checks for whether a certain extension is currently enabled for this node + while (parentNode) { + manualStateChecks.forEach(updateExtensionState); + + // we can abort the search upwards if we leave the contentEditable element + if (Util.isMediumEditorElement(parentNode)) { + break; + } + parentNode = parentNode.parentNode; + } }, + // Positioning toolbar + positionToolbarIfShown: function () { - if (this.isToolbarShown()) { + if (this.isDisplayed()) { this.setToolbarPosition(); } }, - bindWindowActions: function () { - var self = this; + setToolbarPosition: function () { + var container = this.base.getFocusedElement(), + selection = this.window.getSelection(), + anchorPreview; - // Add a scroll event for sticky toolbar - if (this.options.staticToolbar && this.options.stickyToolbar) { - // On scroll, re-position the toolbar - this.on(this.options.contentWindow, 'scroll', function () { - self.positionToolbarIfShown(); - }, true); + // If there isn't a valid selection, bail + if (!container) { + return this; } - this.on(this.options.contentWindow, 'resize', function () { - self.handleResize(); - }); - return this; - }, - - activate: function () { - if (this.isActive) { - return; + if (this.static) { + this.showToolbar(); + this.positionStaticToolbar(container); + } else if (!selection.isCollapsed) { + this.showToolbar(); + this.positionToolbar(selection); } - this.setup(); + anchorPreview = this.base.getExtensionByName('anchor-preview'); + + if (anchorPreview && typeof anchorPreview.hidePreview === 'function') { + anchorPreview.hidePreview(); + } }, - // TODO: break method - deactivate: function () { - var i; - if (!this.isActive) { - return; + positionStaticToolbar: function (container) { + // position the toolbar at left 0, so we can get the real width of the toolbar + this.getToolbarElement().style.left = '0'; + + // document.documentElement for IE 9 + var scrollTop = (this.document.documentElement && this.document.documentElement.scrollTop) || this.document.body.scrollTop, + windowWidth = this.window.innerWidth, + toolbarElement = this.getToolbarElement(), + containerRect = container.getBoundingClientRect(), + containerTop = containerRect.top + scrollTop, + containerCenter = (containerRect.left + (containerRect.width / 2)), + toolbarHeight = toolbarElement.offsetHeight, + toolbarWidth = toolbarElement.offsetWidth, + halfOffsetWidth = toolbarWidth / 2, + targetLeft; + + if (this.sticky) { + // If it's beyond the height of the editor, position it at the bottom of the editor + if (scrollTop > (containerTop + container.offsetHeight - toolbarHeight)) { + toolbarElement.style.top = (containerTop + container.offsetHeight - toolbarHeight) + 'px'; + toolbarElement.classList.remove('medium-editor-sticky-toolbar'); + + // Stick the toolbar to the top of the window + } else if (scrollTop > (containerTop - toolbarHeight)) { + toolbarElement.classList.add('medium-editor-sticky-toolbar'); + toolbarElement.style.top = '0px'; + + // Normal static toolbar position + } else { + toolbarElement.classList.remove('medium-editor-sticky-toolbar'); + toolbarElement.style.top = containerTop - toolbarHeight + 'px'; + } + } else { + toolbarElement.style.top = containerTop - toolbarHeight + 'px'; } - this.isActive = false; - if (this.toolbar !== undefined) { - this.options.elementsContainer.removeChild(this.anchorPreview); - this.options.elementsContainer.removeChild(this.toolbar); - delete this.toolbar; - delete this.anchorPreview; + switch (this.align) { + case 'left': + targetLeft = containerRect.left; + break; + + case 'right': + targetLeft = containerRect.right - toolbarWidth; + break; + + case 'center': + targetLeft = containerCenter - halfOffsetWidth; + break; } - for (i = 0; i < this.elements.length; i += 1) { - this.elements[i].removeAttribute('contentEditable'); - this.elements[i].removeAttribute('data-medium-element'); + if (targetLeft < 0) { + targetLeft = 0; + } else if ((targetLeft + toolbarWidth) > windowWidth) { + targetLeft = (windowWidth - Math.ceil(toolbarWidth) - 1); } - this.removeAllEvents(); + toolbarElement.style.left = targetLeft + 'px'; }, - htmlEntities: function (str) { - // converts special characters (like <) into their escaped/encoded values (like &lt;). - // This allows you to show to display the string without the browser reading it as HTML. - return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); + positionToolbar: function (selection) { + // position the toolbar at left 0, so we can get the real width of the toolbar + this.getToolbarElement().style.left = '0'; + + var windowWidth = this.window.innerWidth, + range = selection.getRangeAt(0), + boundary = range.getBoundingClientRect(), + middleBoundary = (boundary.left + boundary.right) / 2, + toolbarElement = this.getToolbarElement(), + toolbarHeight = toolbarElement.offsetHeight, + toolbarWidth = toolbarElement.offsetWidth, + halfOffsetWidth = toolbarWidth / 2, + buttonHeight = 50, + defaultLeft = this.diffLeft - halfOffsetWidth; + + if (boundary.top < buttonHeight) { + toolbarElement.classList.add('medium-toolbar-arrow-over'); + toolbarElement.classList.remove('medium-toolbar-arrow-under'); + toolbarElement.style.top = buttonHeight + boundary.bottom - this.diffTop + this.window.pageYOffset - toolbarHeight + 'px'; + } else { + toolbarElement.classList.add('medium-toolbar-arrow-under'); + toolbarElement.classList.remove('medium-toolbar-arrow-over'); + toolbarElement.style.top = boundary.top + this.diffTop + this.window.pageYOffset - toolbarHeight + 'px'; + } + + if (middleBoundary < halfOffsetWidth) { + toolbarElement.style.left = defaultLeft + halfOffsetWidth + 'px'; + } else if ((windowWidth - middleBoundary) < halfOffsetWidth) { + toolbarElement.style.left = windowWidth + defaultLeft - halfOffsetWidth + 'px'; + } else { + toolbarElement.style.left = defaultLeft + middleBoundary + 'px'; + } + } + }); +}()); + +var ImageDragging; + +(function () { + 'use strict'; + + ImageDragging = Extension.extend({ + init: function () { + Extension.prototype.init.apply(this, arguments); + + this.subscribe('editableDrag', this.handleDrag.bind(this)); + this.subscribe('editableDrop', this.handleDrop.bind(this)); }, - bindPaste: function () { - var i, self = this; - this.pasteWrapper = function (e) { - var paragraphs, - html = '', - p, - dataFormatHTML = 'text/html', - dataFormatPlain = 'text/plain'; + handleDrag: function (event) { + var className = 'medium-editor-dragover'; + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; - this.classList.remove('medium-editor-placeholder'); - if (!self.options.forcePlainText && !self.options.cleanPastedHTML) { - return this; - } + if (event.type === 'dragover') { + event.target.classList.add(className); + } else if (event.type === 'dragleave') { + event.target.classList.remove(className); + } + }, - if (self.options.contentWindow.clipboardData && e.clipboardData === undefined) { - e.clipboardData = self.options.contentWindow.clipboardData; - // If window.clipboardData exists, but e.clipboardData doesn't exist, - // we're probably in IE. IE only has two possibilities for clipboard - // data format: 'Text' and 'URL'. - // - // Of the two, we want 'Text': - dataFormatHTML = 'Text'; - dataFormatPlain = 'Text'; - } + handleDrop: function (event) { + var className = 'medium-editor-dragover', + files; + event.preventDefault(); + event.stopPropagation(); - if (e.clipboardData && e.clipboardData.getData && !e.defaultPrevented) { - e.preventDefault(); + // IE9 does not support the File API, so prevent file from opening in a new window + // but also don't try to actually get the file + if (event.dataTransfer.files) { + files = Array.prototype.slice.call(event.dataTransfer.files, 0); + files.some(function (file) { + if (file.type.match('image')) { + var fileReader, id; + fileReader = new FileReader(); + fileReader.readAsDataURL(file); - if (self.options.cleanPastedHTML && e.clipboardData.getData(dataFormatHTML)) { - return self.cleanPaste(e.clipboardData.getData(dataFormatHTML)); - } - if (!(self.options.disableReturn || this.getAttribute('data-disable-return'))) { - paragraphs = e.clipboardData.getData(dataFormatPlain).split(/[\r\n]/g); - for (p = 0; p < paragraphs.length; p += 1) { - if (paragraphs[p] !== '') { - if (navigator.userAgent.match(/firefox/i) && p === 0) { - html += self.htmlEntities(paragraphs[p]); - } else { - html += '<p>' + self.htmlEntities(paragraphs[p]) + '</p>'; - } + id = 'medium-img-' + (+new Date()); + Util.insertHTMLCommand(this.document, '<img class="medium-editor-image-loading" id="' + id + '" />'); + + fileReader.onload = function () { + var img = this.document.getElementById(id); + if (img) { + img.removeAttribute('id'); + img.removeAttribute('class'); + img.src = fileReader.result; } - } - insertHTMLCommand(self.options.ownerDocument, html); - } else { - html = self.htmlEntities(e.clipboardData.getData(dataFormatPlain)); - insertHTMLCommand(self.options.ownerDocument, html); + }.bind(this); } - } - }; - for (i = 0; i < this.elements.length; i += 1) { - this.on(this.elements[i], 'paste', this.pasteWrapper); + }.bind(this)); } - return this; - }, + event.target.classList.remove(className); + } + }); +}()); - setPlaceholders: function () { - if (!this.options.disablePlaceholders && this.elements && this.elements.length) { - this.elements.forEach(function (el) { - this.activatePlaceholder(el); - this.on(el, 'blur', this.placeholderWrapper.bind(this)); - this.on(el, 'keypress', this.placeholderWrapper.bind(this)); - }.bind(this)); +var extensionDefaults; +(function () { + // for now this is empty because nothing interally uses an Extension default. + // as they are converted, provide them here. + extensionDefaults = { + button: Button, + form: FormExtension, + + anchor: AnchorForm, + anchorPreview: AnchorPreview, + autoLink: AutoLink, + fileDragging: FileDragging, + fontSize: FontSizeForm, + imageDragging: ImageDragging, // deprecated + keyboardCommands: KeyboardCommands, + paste: PasteHandler, + placeholder: Placeholder, + toolbar: Toolbar + }; +})(); + +function MediumEditor(elements, options) { + 'use strict'; + return this.init(elements, options); +} + +(function () { + 'use strict'; + + // Event handlers that shouldn't be exposed externally + + function handleDisabledEnterKeydown(event, element) { + if (this.options.disableReturn || element.getAttribute('data-disable-return')) { + event.preventDefault(); + } else if (this.options.disableDoubleReturn || element.getAttribute('data-disable-double-return')) { + var node = Selection.getSelectionStart(this.options.ownerDocument); + + // if current text selection is empty OR previous sibling text is empty + if ((node && node.textContent.trim() === '') || + (node.previousElementSibling && node.previousElementSibling.textContent.trim() === '')) { + event.preventDefault(); } + } + } - return this; - }, + function handleTabKeydown(event) { + // Override tab only for pre nodes + var node = Selection.getSelectionStart(this.options.ownerDocument), + tag = node && node.nodeName.toLowerCase(); - cleanPaste: function (text) { + if (tag === 'pre') { + event.preventDefault(); + Util.insertHTMLCommand(this.options.ownerDocument, ' '); + } - /*jslint regexp: true*/ - /* - jslint does not allow character negation, because the negation - will not match any unicode characters. In the regexes in this - block, negation is used specifically to match the end of an html - tag, and in fact unicode characters *should* be allowed. - */ - var i, elList, workEl, - el = this.getSelectionElement(), - multiline = /<p|<br|<div/.test(text), - replacements = [ + // Tab to indent list structures! + if (Util.isListItem(node)) { + event.preventDefault(); - // replace two bogus tags that begin pastes from google docs - [new RegExp(/<[^>]*docs-internal-guid[^>]*>/gi), ""], - [new RegExp(/<\/b>(<br[^>]*>)?$/gi), ""], + // If Shift is down, outdent, otherwise indent + if (event.shiftKey) { + this.options.ownerDocument.execCommand('outdent', false, null); + } else { + this.options.ownerDocument.execCommand('indent', false, null); + } + } + } - // un-html spaces and newlines inserted by OS X - [new RegExp(/<span class="Apple-converted-space">\s+<\/span>/g), ' '], - [new RegExp(/<br class="Apple-interchange-newline">/g), '<br>'], + function handleBlockDeleteKeydowns(event) { + var p, node = Selection.getSelectionStart(this.options.ownerDocument), + tagName = node.nodeName.toLowerCase(), + isEmpty = /^(\s+|<br\/?>)?$/i, + isHeader = /h\d/i; - // replace google docs italics+bold with a span to be replaced once the html is inserted - [new RegExp(/<span[^>]*(font-style:italic;font-weight:bold|font-weight:bold;font-style:italic)[^>]*>/gi), '<span class="replace-with italic bold">'], + if (Util.isKey(event, [Util.keyCode.BACKSPACE, Util.keyCode.ENTER]) && + // has a preceeding sibling + node.previousElementSibling && + // in a header + isHeader.test(tagName) && + // at the very end of the block + Selection.getCaretOffsets(node).left === 0) { + if (Util.isKey(event, Util.keyCode.BACKSPACE) && isEmpty.test(node.previousElementSibling.innerHTML)) { + // backspacing the begining of a header into an empty previous element will + // change the tagName of the current node to prevent one + // instead delete previous node and cancel the event. + node.previousElementSibling.parentNode.removeChild(node.previousElementSibling); + event.preventDefault(); + } else if (Util.isKey(event, Util.keyCode.ENTER)) { + // hitting return in the begining of a header will create empty header elements before the current one + // instead, make "<p><br></p>" element, which are what happens if you hit return in an empty paragraph + p = this.options.ownerDocument.createElement('p'); + p.innerHTML = '<br>'; + node.previousElementSibling.parentNode.insertBefore(p, node); + event.preventDefault(); + } + } else if (Util.isKey(event, Util.keyCode.DELETE) && + // between two sibling elements + node.nextElementSibling && + node.previousElementSibling && + // not in a header + !isHeader.test(tagName) && + // in an empty tag + isEmpty.test(node.innerHTML) && + // when the next tag *is* a header + isHeader.test(node.nextElementSibling.nodeName.toLowerCase())) { + // hitting delete in an empty element preceding a header, ex: + // <p>[CURSOR]</p><h1>Header</h1> + // Will cause the h1 to become a paragraph. + // Instead, delete the paragraph node and move the cursor to the begining of the h1 - // replace google docs italics with a span to be replaced once the html is inserted - [new RegExp(/<span[^>]*font-style:italic[^>]*>/gi), '<span class="replace-with italic">'], + // remove node and move cursor to start of header + Selection.moveCursor(this.options.ownerDocument, node.nextElementSibling); - //[replace google docs bolds with a span to be replaced once the html is inserted - [new RegExp(/<span[^>]*font-weight:bold[^>]*>/gi), '<span class="replace-with bold">'], + node.previousElementSibling.parentNode.removeChild(node); - // replace manually entered b/i/a tags with real ones - [new RegExp(/&lt;(\/?)(i|b|a)&gt;/gi), '<$1$2>'], + event.preventDefault(); + } else if (Util.isKey(event, Util.keyCode.BACKSPACE) && + tagName === 'li' && + // hitting backspace inside an empty li + isEmpty.test(node.innerHTML) && + // is first element (no preceeding siblings) + !node.previousElementSibling && + // parent also does not have a sibling + !node.parentElement.previousElementSibling && + // is not the only li in a list + node.nextElementSibling && + node.nextElementSibling.nodeName.toLowerCase() === 'li') { + // backspacing in an empty first list element in the first list (with more elements) ex: + // <ul><li>[CURSOR]</li><li>List Item 2</li></ul> + // will remove the first <li> but add some extra element before (varies based on browser) + // Instead, this will: + // 1) remove the list element + // 2) create a paragraph before the list + // 3) move the cursor into the paragraph - // replace manually a tags with real ones, converting smart-quotes from google docs - [new RegExp(/&lt;a\s+href=(&quot;|&rdquo;|&ldquo;|“|”)([^&]+)(&quot;|&rdquo;|&ldquo;|“|”)&gt;/gi), '<a href="$2">'] + // create a paragraph before the list + p = this.options.ownerDocument.createElement('p'); + p.innerHTML = '<br>'; + node.parentElement.parentElement.insertBefore(p, node.parentElement); - ]; - /*jslint regexp: false*/ + // move the cursor into the new paragraph + Selection.moveCursor(this.options.ownerDocument, p); - for (i = 0; i < replacements.length; i += 1) { - text = text.replace(replacements[i][0], replacements[i][1]); + // remove the list element + node.parentElement.removeChild(node); + + event.preventDefault(); + } + } + + function handleKeyup(event) { + var node = Selection.getSelectionStart(this.options.ownerDocument), + tagName; + + if (!node) { + return; + } + + if (Util.isMediumEditorElement(node) && node.children.length === 0) { + this.options.ownerDocument.execCommand('formatBlock', false, 'p'); + } + + if (Util.isKey(event, Util.keyCode.ENTER) && !Util.isListItem(node)) { + tagName = node.nodeName.toLowerCase(); + // For anchor tags, unlink + if (tagName === 'a') { + this.options.ownerDocument.execCommand('unlink', false, null); + } else if (!event.shiftKey && !event.ctrlKey) { + // only format block if this is not a header tag + if (!/h\d/.test(tagName)) { + this.options.ownerDocument.execCommand('formatBlock', false, 'p'); + } } + } + } - if (multiline) { + // Internal helper methods which shouldn't be exposed externally - // double br's aren't converted to p tags, but we want paragraphs. - elList = text.split('<br><br>'); + function addToEditors(win) { + if (!win._mediumEditors) { + // To avoid breaking users who are assuming that the unique id on + // medium-editor elements will start at 1, inserting a 'null' in the + // array so the unique-id can always map to the index of the editor instance + win._mediumEditors = [null]; + } - this.pasteHTML('<p>' + elList.join('</p><p>') + '</p>'); - this.options.ownerDocument.execCommand('insertText', false, "\n"); + // If this already has a unique id, re-use it + if (!this.id) { + this.id = win._mediumEditors.length; + } - // block element cleanup - elList = el.querySelectorAll('a,p,div,br'); - for (i = 0; i < elList.length; i += 1) { + win._mediumEditors[this.id] = this; + } - workEl = elList[i]; + function removeFromEditors(win) { + if (!win._mediumEditors || !win._mediumEditors[this.id]) { + return; + } - switch (workEl.tagName.toLowerCase()) { - case 'a': - if (this.options.targetBlank) { - this.setTargetBlank(workEl); - } - break; - case 'p': - case 'div': - this.filterCommonBlocks(workEl); - break; - case 'br': - this.filterLineBreak(workEl); - break; + /* Setting the instance to null in the array instead of deleting it allows: + * 1) Each instance to preserve its own unique-id, even after being destroyed + * and initialized again + * 2) The unique-id to always correspond to an index in the array of medium-editor + * instances. Thus, we will be able to look at a contenteditable, and determine + * which instance it belongs to, by indexing into the global array. + */ + win._mediumEditors[this.id] = null; + } + + function createElementsArray(selector) { + if (!selector) { + selector = []; + } + // If string, use as query selector + if (typeof selector === 'string') { + selector = this.options.ownerDocument.querySelectorAll(selector); + } + // If element, put into array + if (Util.isElement(selector)) { + selector = [selector]; + } + // Convert NodeList (or other array like object) into an array + var elements = Array.prototype.slice.apply(selector); + + // Loop through elements and convert textarea's into divs + this.elements = []; + elements.forEach(function (element, index) { + if (element.nodeName.toLowerCase() === 'textarea') { + this.elements.push(createContentEditable.call(this, element, index)); + } else { + this.elements.push(element); + } + }, this); + } + + function setExtensionDefaults(extension, defaults) { + Object.keys(defaults).forEach(function (prop) { + if (extension[prop] === undefined) { + extension[prop] = defaults[prop]; + } + }); + return extension; + } + + function initExtension(extension, name, instance) { + var extensionDefaults = { + 'window': instance.options.contentWindow, + 'document': instance.options.ownerDocument, + 'base': instance + }; + + // Add default options into the extension + extension = setExtensionDefaults(extension, extensionDefaults); + + // Call init on the extension + if (typeof extension.init === 'function') { + extension.init(); + } + + // Set extension name (if not already set) + if (!extension.name) { + extension.name = name; + } + return extension; + } + + function isToolbarEnabled() { + // If any of the elements don't have the toolbar disabled + // We need a toolbar + if (this.elements.every(function (element) { + return !!element.getAttribute('data-disable-toolbar'); + })) { + return false; + } + + return this.options.toolbar !== false; + } + + function isAnchorPreviewEnabled() { + // If toolbar is disabled, don't add + if (!isToolbarEnabled.call(this)) { + return false; + } + + return this.options.anchorPreview !== false; + } + + function isPlaceholderEnabled() { + return this.options.placeholder !== false; + } + + function isAutoLinkEnabled() { + return this.options.autoLink !== false; + } + + function isImageDraggingEnabled() { + return this.options.imageDragging !== false; + } + + function isKeyboardCommandsEnabled() { + return this.options.keyboardCommands !== false; + } + + function shouldUseFileDraggingExtension() { + // Since the file-dragging extension replaces the image-dragging extension, + // we need to check if the user passed an overrided image-dragging extension. + // If they have, to avoid breaking users, we won't use file-dragging extension. + return !this.options.extensions['imageDragging']; + } + + function createContentEditable(textarea, id) { + var div = this.options.ownerDocument.createElement('div'), + uniqueId = 'medium-editor-' + Date.now() + '-' + id, + attributesToClone = [ + 'data-disable-editing', + 'data-disable-toolbar', + 'data-placeholder', + 'data-disable-return', + 'data-disable-double-return', + 'data-disable-preview', + 'spellcheck' + ]; + + div.className = textarea.className; + div.id = uniqueId; + div.innerHTML = textarea.value; + div.setAttribute('medium-editor-textarea-id', id); + attributesToClone.forEach(function (attr) { + if (textarea.hasAttribute(attr)) { + div.setAttribute(attr, textarea.getAttribute(attr)); + } + }); + + textarea.classList.add('medium-editor-hidden'); + textarea.setAttribute('medium-editor-textarea-id', id); + textarea.parentNode.insertBefore( + div, + textarea + ); + + return div; + } + + function initElements() { + this.elements.forEach(function (element, index) { + if (!this.options.disableEditing && !element.getAttribute('data-disable-editing')) { + element.setAttribute('contentEditable', true); + element.setAttribute('spellcheck', this.options.spellcheck); + } + element.setAttribute('data-medium-editor-element', true); + element.setAttribute('role', 'textbox'); + element.setAttribute('aria-multiline', true); + element.setAttribute('medium-editor-index', index); + + if (element.hasAttribute('medium-editor-textarea-id')) { + this.on(element, 'input', function (event) { + var target = event.target, + textarea = target.parentNode.querySelector('textarea[medium-editor-textarea-id="' + target.getAttribute('medium-editor-textarea-id') + '"]'); + if (textarea) { + textarea.value = this.serialize()[target.id].value; } + }.bind(this)); + } + }, this); + } + function attachHandlers() { + var i; + + // attach to tabs + this.subscribe('editableKeydownTab', handleTabKeydown.bind(this)); + + // Bind keys which can create or destroy a block element: backspace, delete, return + this.subscribe('editableKeydownDelete', handleBlockDeleteKeydowns.bind(this)); + this.subscribe('editableKeydownEnter', handleBlockDeleteKeydowns.bind(this)); + + // disabling return or double return + if (this.options.disableReturn || this.options.disableDoubleReturn) { + this.subscribe('editableKeydownEnter', handleDisabledEnterKeydown.bind(this)); + } else { + for (i = 0; i < this.elements.length; i += 1) { + if (this.elements[i].getAttribute('data-disable-return') || this.elements[i].getAttribute('data-disable-double-return')) { + this.subscribe('editableKeydownEnter', handleDisabledEnterKeydown.bind(this)); + break; } + } + } + // if we're not disabling return, add a handler to help handle cleanup + // for certain cases when enter is pressed + if (!this.options.disableReturn) { + this.elements.forEach(function (element) { + if (!element.getAttribute('data-disable-return')) { + this.on(element, 'keyup', handleKeyup.bind(this)); + } + }, this); + } + } - } else { + function initExtensions() { - this.pasteHTML(text); + this.extensions = []; + // Passed in extensions + Object.keys(this.options.extensions).forEach(function (name) { + // Always save the toolbar extension for last + if (name !== 'toolbar' && this.options.extensions[name]) { + this.extensions.push(initExtension(this.options.extensions[name], name, this)); } + }, this); + // 4 Cases for imageDragging + fileDragging extensons: + // + // 1. ImageDragging ON + No Custom Image Dragging Extension: + // * Use fileDragging extension (default options) + // 2. ImageDragging OFF + No Custom Image Dragging Extension: + // * Use fileDragging extension w/ images turned off + // 3. ImageDragging ON + Custom Image Dragging Extension: + // * Don't use fileDragging (could interfere with custom image dragging extension) + // 4. ImageDragging OFF + Custom Image Dragging: + // * Don't use fileDragging (could interfere with custom image dragging extension) + if (shouldUseFileDraggingExtension.call(this)) { + var opts = this.options.fileDragging; + if (!opts) { + opts = {}; + + // Image is in the 'allowedTypes' list by default. + // If imageDragging is off override the 'allowedTypes' list with an empty one + if (!isImageDraggingEnabled.call(this)) { + opts.allowedTypes = []; + } + } + this.addBuiltInExtension('fileDragging', opts); + } + + // Built-in extensions + var builtIns = { + paste: true, + anchorPreview: isAnchorPreviewEnabled.call(this), + autoLink: isAutoLinkEnabled.call(this), + keyboardCommands: isKeyboardCommandsEnabled.call(this), + placeholder: isPlaceholderEnabled.call(this) + }; + Object.keys(builtIns).forEach(function (name) { + if (builtIns[name]) { + this.addBuiltInExtension(name); + } + }, this); + + // Users can pass in a custom toolbar extension + // so check for that first and if it's not present + // just create the default toolbar + var toolbarExtension = this.options.extensions['toolbar']; + if (!toolbarExtension && isToolbarEnabled.call(this)) { + // Backwards compatability + var toolbarOptions = Util.extend({}, this.options.toolbar, { + allowMultiParagraphSelection: this.options.allowMultiParagraphSelection // deprecated + }); + toolbarExtension = new MediumEditor.extensions.toolbar(toolbarOptions); + } + + // If the toolbar is not disabled, so we actually have an extension + // initialize it and add it to the extensions array + if (toolbarExtension) { + this.extensions.push(initExtension(toolbarExtension, 'toolbar', this)); + } + } + + function mergeOptions(defaults, options) { + var deprecatedProperties = [ + ['allowMultiParagraphSelection', 'toolbar.allowMultiParagraphSelection'] + ]; + // warn about using deprecated properties + if (options) { + deprecatedProperties.forEach(function (pair) { + if (options.hasOwnProperty(pair[0]) && options[pair[0]] !== undefined) { + Util.deprecated(pair[0], pair[1], 'v6.0.0'); + } + }); + } + + return Util.defaults({}, options, defaults); + } + + function execActionInternal(action, opts) { + /*jslint regexp: true*/ + var appendAction = /^append-(.+)$/gi, + justifyAction = /justify([A-Za-z]*)$/g, /* Detecting if is justifyCenter|Right|Left */ + match; + /*jslint regexp: false*/ + + // Actions starting with 'append-' should attempt to format a block of text ('formatBlock') using a specific + // type of block element (ie append-blockquote, append-h1, append-pre, etc.) + match = appendAction.exec(action); + if (match) { + return Util.execFormatBlock(this.options.ownerDocument, match[1]); + } + + if (action === 'fontSize') { + return this.options.ownerDocument.execCommand('fontSize', false, opts.size); + } + + if (action === 'createLink') { + return this.createLink(opts); + } + + if (action === 'image') { + return this.options.ownerDocument.execCommand('insertImage', false, this.options.contentWindow.getSelection()); + } + + /* Issue: https://github.com/yabwe/medium-editor/issues/595 + * If the action is to justify the text */ + if (justifyAction.exec(action)) { + var result = this.options.ownerDocument.execCommand(action, false, null), + parentNode = Selection.getSelectedParentElement(Selection.getSelectionRange(this.options.ownerDocument)); + if (parentNode) { + cleanupJustifyDivFragments.call(this, Util.getTopBlockContainer(parentNode)); + } + + return result; + } + + return this.options.ownerDocument.execCommand(action, false, null); + } + + /* If we've just justified text within a container block + * Chrome may have removed <br> elements and instead wrapped lines in <div> elements + * with a text-align property. If so, we want to fix this + */ + function cleanupJustifyDivFragments(blockContainer) { + if (!blockContainer) { + return; + } + + var textAlign, + childDivs = Array.prototype.slice.call(blockContainer.childNodes).filter(function (element) { + var isDiv = element.nodeName.toLowerCase() === 'div'; + if (isDiv && !textAlign) { + textAlign = element.style.textAlign; + } + return isDiv; + }); + + /* If we found child <div> elements with text-align style attributes + * we should fix this by: + * + * 1) Unwrapping each <div> which has a text-align style + * 2) Insert a <br> element after each set of 'unwrapped' div children + * 3) Set the text-align style of the parent block element + */ + if (childDivs.length) { + // Since we're mucking with the HTML, preserve selection + this.saveSelection(); + childDivs.forEach(function (div) { + if (div.style.textAlign === textAlign) { + var lastChild = div.lastChild; + if (lastChild) { + // Instead of a div, extract the child elements and add a <br> + Util.unwrap(div, this.options.ownerDocument); + var br = this.options.ownerDocument.createElement('BR'); + lastChild.parentNode.insertBefore(br, lastChild.nextSibling); + } + } + }, this); + blockContainer.style.textAlign = textAlign; + // We're done, so restore selection + this.restoreSelection(); + } + } + + MediumEditor.Extension = Extension; + + MediumEditor.extensions = extensionDefaults; + MediumEditor.util = Util; + MediumEditor.selection = Selection; + + MediumEditor.prototype = { + defaults: editorDefaults, + + // NOT DOCUMENTED - exposed for backwards compatability + init: function (elements, options) { + this.options = mergeOptions.call(this, this.defaults, options); + this.origElements = elements; + + if (!this.options.elementsContainer) { + this.options.elementsContainer = this.options.ownerDocument.body; + } + + return this.setup(); }, - pasteHTML: function (html) { - var elList, workEl, i, fragmentBody, pasteBlock = this.options.ownerDocument.createDocumentFragment(); + setup: function () { + if (this.isActive) { + return; + } - pasteBlock.appendChild(this.options.ownerDocument.createElement('body')); + createElementsArray.call(this, this.origElements); - fragmentBody = pasteBlock.querySelector('body'); - fragmentBody.innerHTML = html; + if (this.elements.length === 0) { + return; + } - this.cleanupSpans(fragmentBody); + this.isActive = true; + addToEditors.call(this, this.options.contentWindow); - elList = fragmentBody.querySelectorAll('*'); - for (i = 0; i < elList.length; i += 1) { + this.events = new Events(this); - workEl = elList[i]; + // Call initialization helpers + initElements.call(this); + initExtensions.call(this); + attachHandlers.call(this); + }, - // delete ugly attributes - workEl.removeAttribute('class'); - workEl.removeAttribute('style'); - workEl.removeAttribute('dir'); + destroy: function () { + if (!this.isActive) { + return; + } - if (workEl.tagName.toLowerCase() === 'meta') { - workEl.parentNode.removeChild(workEl); + this.isActive = false; + + this.extensions.forEach(function (extension) { + if (typeof extension.destroy === 'function') { + extension.destroy(); } + }, this); + this.events.destroy(); + + this.elements.forEach(function (element) { + // Reset elements content, fix for issue where after editor destroyed the red underlines on spelling errors are left + if (this.options.spellcheck) { + element.innerHTML = element.innerHTML; + } + + // cleanup extra added attributes + element.removeAttribute('contentEditable'); + element.removeAttribute('spellcheck'); + element.removeAttribute('data-medium-editor-element'); + element.removeAttribute('role'); + element.removeAttribute('aria-multiline'); + element.removeAttribute('medium-editor-index'); + + // Remove any elements created for textareas + if (element.hasAttribute('medium-editor-textarea-id')) { + var textarea = element.parentNode.querySelector('textarea[medium-editor-textarea-id="' + element.getAttribute('medium-editor-textarea-id') + '"]'); + if (textarea) { + // Un-hide the textarea + textarea.classList.remove('medium-editor-hidden'); + } + if (element.parentNode) { + element.parentNode.removeChild(element); + } + } + }, this); + this.elements = []; + + removeFromEditors.call(this, this.options.contentWindow); + }, + + on: function (target, event, listener, useCapture) { + this.events.attachDOMEvent(target, event, listener, useCapture); + }, + + off: function (target, event, listener, useCapture) { + this.events.detachDOMEvent(target, event, listener, useCapture); + }, + + subscribe: function (event, listener) { + this.events.attachCustomEvent(event, listener); + }, + + unsubscribe: function (event, listener) { + this.events.detachCustomEvent(event, listener); + }, + + trigger: function (name, data, editable) { + this.events.triggerCustomEvent(name, data, editable); + }, + + delay: function (fn) { + var self = this; + return setTimeout(function () { + if (self.isActive) { + fn(); + } + }, this.options.delay); + }, + + serialize: function () { + var i, + elementid, + content = {}; + for (i = 0; i < this.elements.length; i += 1) { + elementid = (this.elements[i].id !== '') ? this.elements[i].id : 'element-' + i; + content[elementid] = { + value: this.elements[i].innerHTML.trim() + }; } - this.options.ownerDocument.execCommand('insertHTML', false, fragmentBody.innerHTML.replace(/&nbsp;/g, ' ')); + return content; }, - isCommonBlock: function (el) { - return (el && (el.tagName.toLowerCase() === 'p' || el.tagName.toLowerCase() === 'div')); + + getExtensionByName: function (name) { + var extension; + if (this.extensions && this.extensions.length) { + this.extensions.some(function (ext) { + if (ext.name === name) { + extension = ext; + return true; + } + return false; + }); + } + return extension; }, - filterCommonBlocks: function (el) { - if (/^\s*$/.test(el.textContent)) { - el.parentNode.removeChild(el); + + /** + * NOT DOCUMENTED - exposed as a helper for other extensions to use + */ + addBuiltInExtension: function (name, opts) { + var extension = this.getExtensionByName(name), + merged; + if (extension) { + return extension; } + + switch (name) { + case 'anchor': + merged = Util.extend({}, this.options.anchor, opts); + extension = new MediumEditor.extensions.anchor(merged); + break; + case 'anchorPreview': + extension = new MediumEditor.extensions.anchorPreview(this.options.anchorPreview); + break; + case 'autoLink': + extension = new MediumEditor.extensions.autoLink(); + break; + case 'fileDragging': + extension = new MediumEditor.extensions.fileDragging(opts); + break; + case 'fontsize': + extension = new MediumEditor.extensions.fontSize(opts); + break; + case 'keyboardCommands': + extension = new MediumEditor.extensions.keyboardCommands(this.options.keyboardCommands); + break; + case 'paste': + extension = new MediumEditor.extensions.paste(this.options.paste); + break; + case 'placeholder': + extension = new MediumEditor.extensions.placeholder(this.options.placeholder); + break; + default: + // All of the built-in buttons for MediumEditor are extensions + // so check to see if the extension we're creating is a built-in button + if (MediumEditor.extensions.button.isBuiltInButton(name)) { + if (opts) { + merged = Util.defaults({}, opts, MediumEditor.extensions.button.prototype.defaults[name]); + extension = new MediumEditor.extensions.button(merged); + } else { + extension = new MediumEditor.extensions.button(name); + } + } + } + + if (extension) { + this.extensions.push(initExtension(extension, name, this)); + } + + return extension; }, - filterLineBreak: function (el) { - if (this.isCommonBlock(el.previousElementSibling)) { - // remove stray br's following common block elements - el.parentNode.removeChild(el); + stopSelectionUpdates: function () { + this.preventSelectionUpdates = true; + }, - } else if (this.isCommonBlock(el.parentNode) && (el.parentNode.firstChild === el || el.parentNode.lastChild === el)) { + startSelectionUpdates: function () { + this.preventSelectionUpdates = false; + }, - // remove br's just inside open or close tags of a div/p - el.parentNode.removeChild(el); + checkSelection: function () { + var toolbar = this.getExtensionByName('toolbar'); + if (toolbar) { + toolbar.checkState(); + } + return this; + }, - } else if (el.parentNode.childElementCount === 1) { + // Wrapper around document.queryCommandState for checking whether an action has already + // been applied to the current selection + queryCommandState: function (action) { + var fullAction = /^full-(.+)$/gi, + match, + queryState = null; - // and br's that are the only child of a div/p - this.removeWithParent(el); + // Actions starting with 'full-' need to be modified since this is a medium-editor concept + match = fullAction.exec(action); + if (match) { + action = match[1]; + } + try { + queryState = this.options.ownerDocument.queryCommandState(action); + } catch (exc) { + queryState = null; } + return queryState; }, - // remove an element, including its parent, if it is the only element within its parent - removeWithParent: function (el) { - if (el && el.parentNode) { - if (el.parentNode.parentNode && el.parentNode.childElementCount === 1) { - el.parentNode.parentNode.removeChild(el.parentNode); - } else { - el.parentNode.removeChild(el.parentNode); + execAction: function (action, opts) { + /*jslint regexp: true*/ + var fullAction = /^full-(.+)$/gi, + match, + result; + /*jslint regexp: false*/ + + // Actions starting with 'full-' should be applied to to the entire contents of the editable element + // (ie full-bold, full-append-pre, etc.) + match = fullAction.exec(action); + if (match) { + // Store the current selection to be restored after applying the action + this.saveSelection(); + // Select all of the contents before calling the action + this.selectAllContents(); + result = execActionInternal.call(this, match[1], opts); + // Restore the previous selection + this.restoreSelection(); + } else { + result = execActionInternal.call(this, action, opts); + } + + // do some DOM clean-up for known browser issues after the action + if (action === 'insertunorderedlist' || action === 'insertorderedlist') { + Util.cleanListDOM(this.options.ownerDocument, this.getSelectedParentElement()); + } + + this.checkSelection(); + return result; + }, + + getSelectedParentElement: function (range) { + if (range === undefined) { + range = this.options.contentWindow.getSelection().getRangeAt(0); + } + return Selection.getSelectedParentElement(range); + }, + + selectAllContents: function () { + var currNode = Selection.getSelectionElement(this.options.contentWindow); + + if (currNode) { + // Move to the lowest descendant node that still selects all of the contents + while (currNode.children.length === 1) { + currNode = currNode.children[0]; } + + this.selectElement(currNode); } }, - cleanupSpans: function (container_el) { + selectElement: function (element) { + Selection.selectNode(element, this.options.ownerDocument); - var i, - el, - new_el, - spans = container_el.querySelectorAll('.replace-with'); + var selElement = Selection.getSelectionElement(this.options.contentWindow); + if (selElement) { + this.events.focusElement(selElement); + } + }, - for (i = 0; i < spans.length; i += 1) { + getFocusedElement: function () { + var focused; + this.elements.some(function (element) { + // Find the element that has focus + if (!focused && element.getAttribute('data-medium-focused')) { + focused = element; + } - el = spans[i]; - new_el = this.options.ownerDocument.createElement(el.classList.contains('bold') ? 'b' : 'i'); + // bail if we found the element that had focus + return !!focused; + }, this); - if (el.classList.contains('bold') && el.classList.contains('italic')) { + return focused; + }, - // add an i tag as well if this has both italics and bold - new_el.innerHTML = '<i>' + el.innerHTML + '</i>'; + // Export the state of the selection in respect to one of this + // instance of MediumEditor's elements + exportSelection: function () { + var selectionElement = Selection.getSelectionElement(this.options.contentWindow), + editableElementIndex = this.elements.indexOf(selectionElement), + selectionState = null; - } else { + if (editableElementIndex >= 0) { + selectionState = Selection.exportSelection(selectionElement, this.options.ownerDocument); + } - new_el.innerHTML = el.innerHTML; + if (selectionState !== null && editableElementIndex !== 0) { + selectionState.editableElementIndex = editableElementIndex; + } - } - el.parentNode.replaceChild(new_el, el); + return selectionState; + }, + saveSelection: function () { + this.selectionState = this.exportSelection(); + }, + + // Restore a selection based on a selectionState returned by a call + // to MediumEditor.exportSelection + importSelection: function (selectionState, favorLaterSelectionAnchor) { + if (!selectionState) { + return; } - spans = container_el.querySelectorAll('span'); - for (i = 0; i < spans.length; i += 1) { + var editableElement = this.elements[selectionState.editableElementIndex || 0]; + Selection.importSelection(selectionState, editableElement, this.options.ownerDocument, favorLaterSelectionAnchor); + }, - el = spans[i]; + restoreSelection: function () { + this.importSelection(this.selectionState); + }, - // remove empty spans, replace others with their contents - if (/^\s*$/.test()) { - el.parentNode.removeChild(el); - } else { - el.parentNode.replaceChild(this.options.ownerDocument.createTextNode(el.textContent), el); + createLink: function (opts) { + var customEvent, + i; + + if (opts.url && opts.url.trim().length > 0) { + var currentSelection = this.options.contentWindow.getSelection(); + if (currentSelection) { + var exportedSelection, + startContainerParentElement, + endContainerParentElement, + textNodes; + + startContainerParentElement = Util.getClosestBlockContainer( + currentSelection.getRangeAt(0).startContainer); + endContainerParentElement = Util.getClosestBlockContainer( + currentSelection.getRangeAt(0).endContainer); + + if (startContainerParentElement === endContainerParentElement) { + var currentEditor = Selection.getSelectionElement(this.options.contentWindow), + parentElement = (startContainerParentElement || currentEditor), + fragment = this.options.ownerDocument.createDocumentFragment(); + exportedSelection = this.exportSelection(); + fragment.appendChild(parentElement.cloneNode(true)); + if (currentEditor === parentElement) { + // We have to avoid the editor itself being wiped out when it's the only block element, + // as our reference inside this.elements gets detached from the page when insertHTML runs. + // If we just use [parentElement, 0] and [parentElement, parentElement.childNodes.length] + // as the range boundaries, this happens whenever parentElement === currentEditor. + // The tradeoff to this workaround is that a orphaned tag can sometimes be left behind at + // the end of the editor's content. + // In Gecko: + // as an empty <strong></strong> if parentElement.lastChild is a <strong> tag. + // In WebKit: + // an invented <br /> tag at the end in the same situation + + Selection.select(this.options.ownerDocument, + parentElement.firstChild, 0, + parentElement.lastChild, parentElement.lastChild.nodeType === 3 ? + parentElement.lastChild.nodeValue.length : parentElement.lastChild.childNodes.length); + } else { + Selection.select(this.options.ownerDocument, + parentElement, 0, + parentElement, parentElement.childNodes.length); + } + var modifiedExportedSelection = this.exportSelection(); + + textNodes = Util.findOrCreateMatchingTextNodes(this.options.ownerDocument, + fragment, + { + start: exportedSelection.start - modifiedExportedSelection.start, + end: exportedSelection.end - modifiedExportedSelection.start, + editableElementIndex: exportedSelection.editableElementIndex + }); + // Creates the link in the document fragment + Util.createLink(this.options.ownerDocument, textNodes, opts.url.trim()); + // Chrome trims the leading whitespaces when inserting HTML, which messes up restoring the selection. + var leadingWhitespacesCount = (fragment.firstChild.innerHTML.match(/^\s+/) || [''])[0].length; + // Now move the created link back into the original document in a way to preserve undo/redo history + Util.insertHTMLCommand(this.options.ownerDocument, + fragment.firstChild.innerHTML.replace(/^\s+/, '')); + exportedSelection.start -= leadingWhitespacesCount; + exportedSelection.end -= leadingWhitespacesCount; + this.importSelection(exportedSelection); + } else { + this.options.ownerDocument.execCommand('createLink', false, opts.url); + } + if (this.options.targetBlank || opts.target === '_blank') { + Util.setTargetBlank(Selection.getSelectionStart(this.options.ownerDocument), opts.url); + } + + if (opts.buttonClass) { + Util.addClassToAnchors(Selection.getSelectionStart(this.options.ownerDocument), opts.buttonClass); + } } + } + if (this.options.targetBlank || opts.target === '_blank' || opts.buttonClass) { + customEvent = this.options.ownerDocument.createEvent('HTMLEvents'); + customEvent.initEvent('input', true, true, this.options.contentWindow); + for (i = 0; i < this.elements.length; i += 1) { + this.elements[i].dispatchEvent(customEvent); + } } + }, + cleanPaste: function (text) { + this.getExtensionByName('paste').cleanPaste(text); + }, + + pasteHTML: function (html, options) { + this.getExtensionByName('paste').pasteHTML(html, options); } + }; +}()); +MediumEditor.parseVersionString = function (release) { + var split = release.split('-'), + version = split[0].split('.'), + preRelease = (split.length > 1) ? split[1] : ''; + return { + major: parseInt(version[0], 10), + minor: parseInt(version[1], 10), + revision: parseInt(version[2], 10), + preRelease: preRelease, + toString: function () { + return [version[0], version[1], version[2]].join('.') + (preRelease ? '-' + preRelease : ''); + } }; +}; -}(window, document)); +MediumEditor.version = MediumEditor.parseVersionString.call(this, ({ + // grunt-bump looks for this: + 'version': '5.4.0' +}).version); + + return MediumEditor; +}()));