diff --git a/codepot/src/js/Makefile.am b/codepot/src/js/Makefile.am index e2f7088e..f12d9e2b 100644 --- a/codepot/src/js/Makefile.am +++ b/codepot/src/js/Makefile.am @@ -24,7 +24,7 @@ www_DATA = \ excanvas.min.js \ jquery.flot.tickrotor.js \ jqueryui-editable.min.js \ - medium-editor.min.js \ + medium-editor.js \ medium-editor-tables.js \ showdown.js \ d3.min.js \ diff --git a/codepot/src/js/Makefile.in b/codepot/src/js/Makefile.in index cd24eccc..7d474129 100644 --- a/codepot/src/js/Makefile.in +++ b/codepot/src/js/Makefile.in @@ -177,7 +177,7 @@ www_DATA = \ excanvas.min.js \ jquery.flot.tickrotor.js \ jqueryui-editable.min.js \ - medium-editor.min.js \ + medium-editor.js \ medium-editor-tables.js \ showdown.js \ d3.min.js \ diff --git a/codepot/src/js/medium-editor.js b/codepot/src/js/medium-editor.js new file mode 100644 index 00000000..eaa0d834 --- /dev/null +++ b/codepot/src/js/medium-editor.js @@ -0,0 +1,6887 @@ +/*global self, document, DOMException */ + +/*! @source http://purl.eligrey.com/github/classList.js/blob/master/classList.js */ + +// Full polyfill for browsers with no classList support +if (!("classList" in document.createElement("_"))) { + (function (view) { + + "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; + } + } + 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); + + 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); + + if (updated) { + this._updateClassName(); + } + }; + classListProto.toggle = function (token, force) { + token += ""; + + var + result = this.contains(token) + , method = result ? + force !== true && "remove" + : + force !== false && "add" + ; + + if (method) { + this[method](token); + } + + if (force === true || force === false) { + return force; + } else { + return !result; + } + }; + classListProto.toString = function () { + return this.join(" "); + }; + + 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); + } + + }(self)); +} + +/* 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 + */ + +/*global self, unescape */ +/*jslint bitwise: true, regexp: true, confusion: true, es5: true, vars: true, white: true, + plusplus: true */ + +/*! @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 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)); + + 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; + }; + + 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 () { + + 'use strict'; + +function MediumEditor(elements, options) { + 'use strict'; + return this.init(elements, options); +} + +MediumEditor.extensions = {}; +/*jshint unused: true */ +(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]; + } + } + } + } + return dest; + } + + // https://developer.mozilla.org/en-US/docs/Web/API/Node/contains + // Some browsers (including phantom) don't return true for Node.contains(child) + // if child is a text node. Detect these cases here and use a fallback + // for calls to Util.isDescendant() + var nodeContainsWorksWithTextNodes = false; + try { + var testParent = document.createElement('div'), + testText = document.createTextNode(' '); + testParent.appendChild(testText); + nodeContainsWorksWithTextNodes = testParent.contains(testText); + } catch (exc) {} + + var Util = { + + // 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))), + + // if firefox + isFF: (navigator.userAgent.toLowerCase().indexOf('firefox') > -1), + + // http://stackoverflow.com/a/11752084/569101 + isMac: (window.navigator.platform.toUpperCase().indexOf('MAC') >= 0), + + // 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 + M: 77 + }, + + /** + * Returns true if it's metaKey on Mac, or ctrlKey on non-Mac. + * See #591 + */ + isMetaCtrlKey: function (event) { + if ((Util.isMac && event.metaKey) || (!Util.isMac && event.ctrlKey)) { + return true; + } + + 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 = Util.getKeyCode(event); + + // it's not an array let's just compare strings! + if (false === Array.isArray(keys)) { + return keyCode === keys; + } + + if (-1 === keys.indexOf(keyCode)) { + return false; + } + + return true; + }, + + getKeyCode: function (event) { + var keyCode = event.which; + + // getting the key code from event + if (null === keyCode) { + keyCode = event.charCode !== null ? event.charCode : event.keyCode; + } + + return keyCode; + }, + + blockContainerElementNames: [ + // elements our editor generates + 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'ul', 'li', 'ol', + // all other known block elements + 'address', 'article', 'aside', 'audio', 'canvas', 'dd', 'dl', 'dt', 'fieldset', + 'figcaption', 'figure', 'footer', 'form', 'header', 'hgroup', 'main', 'nav', + 'noscript', 'output', 'section', 'video', + 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td' + ], + + 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); + }, + + defaults: function defaults(/*dest, source1, source2, ...*/) { + var args = [false].concat(Array.prototype.slice.call(arguments)); + return copyInto.apply(this, args); + }, + + /* + * 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; + }, + + /* + * 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_ALL, null, false), + matchedNodes = [], + currentTextIndex = 0, + startReached = false, + currentNode = null, + newNode = null; + + while ((currentNode = treeWalker.nextNode()) !== null) { + if (currentNode.nodeType > 3) { + continue; + } else if (currentNode.nodeType === 3) { + 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; + } else if (currentNode.tagName.toLowerCase() === 'img') { + if (!startReached && (match.start <= currentTextIndex)) { + startReached = true; + } + if (startReached) { + matchedNodes.push(currentNode); + } + } + } + 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; + }, + + /* + * 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); + } + }, + + /* + * Take an element, and break up all of its text content into unique pieces such that: + * 1) All text content of the elements are in separate blocks. No piece of text content should span + * across multiple blocks. This means no element return by this function should have + * any blocks as children. + * 2) The union of the textcontent of all of the elements returned here covers all + * of the text within the element. + * + * + * EXAMPLE: + * In the event that we have something like: + * + *
+ *+ * + * This function would return these elements as an array: + * [Some Text
+ *+ *
+ *- List Item 1
+ *- List Item 2
+ *
Some Text
,andelements contain blocks within them they are not returned. + * Since the
and
- 's don't contain block elements and cover all the text content of the + *
container, they are the elements returned. + */ + splitByBlockElements: function (element) { + if (element.nodeType !== 3 && element.nodeType !== 1) { + return []; + } + + var toRet = [], + blockElementQuery = MediumEditor.util.blockContainerElementNames.join(','); + + if (element.nodeType === 3 || element.querySelectorAll(blockElementQuery).length === 0) { + return [element]; + } + + for (var i = 0; i < element.childNodes.length; i++) { + var child = element.childNodes[i]; + if (child.nodeType === 3) { + toRet.push(child); + } else if (child.nodeType === 1) { + var blockElements = child.querySelectorAll(blockElementQuery); + if (blockElements.length === 0) { + toRet.push(child); + } else { + toRet = toRet.concat(MediumEditor.util.splitByBlockElements(child)); + } + } + } + + return toRet; + }, + + // 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); + + // 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; + }, + + isDescendant: function isDescendant(parent, child, checkEquality) { + if (!parent || !child) { + return false; + } + if (parent === child) { + return !!checkEquality; + } + // If parent is not an element, it can't have any descendants + if (parent.nodeType !== 1) { + return false; + } + if (nodeContainsWorksWithTextNodes || child.nodeType !== 3) { + return parent.contains(child); + } + var node = child.parentNode; + while (node !== null) { + if (node === parent) { + return true; + } + node = node.parentNode; + } + return false; + }, + + // https://github.com/jashkenas/underscore + isElement: function isElement(obj) { + return !!(obj && obj.nodeType === 1); + }, + + // 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; + } + + 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 result; + }; + }, + + 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; + } + } + + current = current.parentNode; + } while (current); + + return false; + }, + + htmlEntities: function (str) { + // converts special characters (like <) into their escaped/encoded values (like <). + // This allows you to show to display the string without the browser reading it as HTML. + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + }, + + // 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.getSelection(); + if (selection.rangeCount) { + range = selection.getRangeAt(0); + toReplace = range.commonAncestorContainer; + + // https://github.com/yabwe/medium-editor/issues/748 + // If the selection is an empty editor element, create a temporary text node inside of the editor + // and select it so that we don't delete the editor element + if (Util.isMediumEditorElement(toReplace) && !toReplace.firstChild) { + range.selectNode(toReplace.appendChild(doc.createTextNode(''))); + } else if ((toReplace.nodeType === 3 && range.startOffset === 0 && range.endOffset === toReplace.nodeValue.length) || + (toReplace.nodeType !== 3 && toReplace.innerHTML === range.toString())) { + // Ensure range covers maximum amount of nodes as possible + // By moving up the DOM and selecting ancestors whose only child is the range + while (!Util.isMediumEditorElement(toReplace) && + 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); + } + } + }, + + execFormatBlock: function (doc, tagName) { + // Get the top level block element that contains the selection + var blockContainer = Util.getTopBlockContainer(MediumEditor.selection.getSelectionStart(doc)), + childNodes; + + // Special handling for blockquote + if (tagName === 'blockquote') { + if (blockContainer) { + 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); + } + } + + // When IE blockquote needs to be called as indent + // http://stackoverflow.com/questions/1816223/rich-text-editor-with-blockquote-function/1821777#1821777 + if (Util.isIE) { + return doc.execCommand('indent', false, tagName); + } + } + + // If the blockContainer is already the element type being passed in + // treat it as 'undo' formatting and just convert it to a+ 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 (Util.isIE) { + tagName = '<' + tagName + '>'; + } + + // When FF or IE, we have to handle blockquote node seperately as 'formatblock' does not work. + // https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand#Commands + if (blockContainer && blockContainer.nodeName.toLowerCase() === 'blockquote') { + // For IE, just use outdent + if (Util.isIE && tagName === '
') { + return doc.execCommand('outdent', false, tagName); + } + + // For Firefox, make sure there's a nested block element before calling outdent + if (Util.isFF && tagName === 'p') { + childNodes = Array.prototype.slice.call(blockContainer.childNodes); + // If there are some non-block elements we need to wrap everything in a
before we outdent + if (childNodes.some(function (childNode) { + return !Util.isBlockContainer(childNode); + })) { + doc.execCommand('formatBlock', false, tagName); + } + return doc.execCommand('outdent', false, tagName); + } + } + + return doc.execCommand('formatBlock', false, tagName); + }, + + /** + * 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'; + } + } + } + }, + + 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]); + } + } 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]); + } + } + } + }, + + isListItem: function (node) { + if (!node) { + return false; + } + if (node.nodeName.toLowerCase() === 'li') { + return true; + } + + var parentNode = node.parentNode, + tagName = parentNode.nodeName.toLowerCase(); + while (tagName === 'li' || (!Util.isBlockContainer(parentNode) && tagName !== 'div')) { + if (tagName === 'li') { + return true; + } + parentNode = parentNode.parentNode; + if (parentNode) { + tagName = parentNode.nodeName.toLowerCase(); + } else { + return false; + } + } + return false; + }, + + 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 + Util.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 + MediumEditor.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: + * + *
+ * / | \ + * + * / \ / \ / \ + * 1 2 3 4 5 6 + * + * If I wanted to split this tree given theas 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 + * + *''+ * / \ / \ / \ | + * ' + * / \ | | / \ /\ /\ /\ + * 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' would be returned as an element not appended to the DOM, and the+ * 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; + } + } + createdNode = newParent; + + 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); + } + + 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; + } + } + + // If we had an element we wanted to append at the end, do that now + if (appendLast) { + createdNode.appendChild(appendLast); + } + + splitOnNode = currParent; + } + + return createdNode; + }, + + moveTextRangeIntoElement: function (startNode, endNode, newElement) { + if (!startNode || !endNode) { + return false; + } + + var rootNode = Util.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(); + } + + // 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 (Util.isDescendant(nextNode, startNode, true)) { + firstChild = nextNode; + } + } else { + if (Util.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(Util.splitOffDOMTree(firstChild, startNode)); + } + + // add any elements between firstChild & lastChild + rootChildren.forEach(function (element) { + element.parentNode.removeChild(element); + fragment.appendChild(element); + }); + + // build up fragment on endNode side of the tree + if (lastChild === endNode) { + lastChild.parentNode.removeChild(lastChild); + fragment.appendChild(lastChild); + } else { + fragment.appendChild(Util.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(); + }, + + /* 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; + }, + + findCommonRoot: function (inNode1, inNode2) { + var depth1 = Util.depthOfNode(inNode1), + depth2 = Util.depthOfNode(inNode2), + node1 = inNode1, + node2 = inNode2; + + while (depth1 !== depth2) { + if (depth1 > depth2) { + node1 = node1.parentNode; + depth1 -= 1; + } else { + node2 = node2.parentNode; + depth2 -= 1; + } + } + + while (node1 !== node2) { + node1 = node1.parentNode; + node2 = node2.parentNode; + } + + return node1; + }, + /* END - based on http://stackoverflow.com/a/6183069 */ + + isElementAtBeginningOfBlock: function (node) { + var textVal, + sibling; + while (!Util.isBlockContainer(node) && !Util.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; + }, + + 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 && Util.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; + Util.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 = Util.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; + }, + + getFirstTextNode: function (element) { + if (element.nodeType === 3) { + return element; + } + + for (var i = 0; i < element.childNodes.length; i++) { + var textNode = Util.getFirstTextNode(element.childNodes[i]); + if (textNode !== null) { + return textNode; + } + } + return null; + }, + + ensureUrlHasProtocol: function (url) { + if (url.indexOf('://') === -1) { + return 'http://' + url; + } + return url; + }, + + warn: function () { + if (window.console !== undefined && typeof window.console.warn === 'function') { + window.console.warn.apply(window.console, arguments); + } + }, + + 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); + }, + + 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); + } + }); + }, + + // get the closest parent + getClosestTag: function (el, tag) { + return Util.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]); + } + + if (fragment.childNodes.length) { + el.parentNode.replaceChild(fragment, el); + } else { + el.parentNode.removeChild(el); + } + } + }; + + MediumEditor.util = Util; +}(window)); + +(function () { + 'use strict'; + + var Extension = function (options) { + MediumEditor.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) + MediumEditor.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) { + MediumEditor.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 + */ + init: function () {}, + + /* base: [MediumEditor instance] + * + * If not overriden, this will be set to the current instance + * of MediumEditor, before the init method is called + */ + base: undefined, + + /* 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); + }; + }); + + MediumEditor.Extension = Extension; +})(); + +(function () { + 'use strict'; + + function filterOnlyParentElements(node) { + if (MediumEditor.util.isBlockContainer(node)) { + return NodeFilter.FILTER_ACCEPT; + } else { + return NodeFilter.FILTER_SKIP; + } + } + + var 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 MediumEditor.util.traverseUp(current, testElementFunction); + }, + + getSelectionElement: function (contentWindow) { + return this.findMatchingSelectionParent(function (el) { + return MediumEditor.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 + }; + + // Range contains an image, check to see if the selection ends with that image + if (range.endOffset !== 0 && (range.endContainer.nodeName.toLowerCase() === 'img' || (range.endContainer.nodeType === 1 && range.endContainer.querySelector('img')))) { + var trailingImageCount = this.getTrailingImageCount(root, selectionState, range.endContainer, range.endOffset); + if (trailingImageCount) { + selectionState.trailingImageCount = trailingImageCount; + } + } + + // 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 !== -1) { + selectionState.emptyBlocksIndex = emptyBlocksIndex; + } + } + } + + return selectionState; + }, + + // 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, + foundEnd = false, + trailingImageCount = 0, + stop = false, + nextCharIndex; + + while (!stop && node) { + // Only iterate over elements and text nodes + if (node.nodeType > 3) { + continue; + } + + // If we hit a text node, we need to add the amount of characters to the overall count + if (node.nodeType === 3 && !foundEnd) { + 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) { + if (!selectionState.trailingImageCount) { + range.setEnd(node, selectionState.end - charIndex); + stop = true; + } else { + foundEnd = true; + } + } + charIndex = nextCharIndex; + } else { + if (selectionState.trailingImageCount && foundEnd) { + if (node.nodeName.toLowerCase() === 'img') { + trailingImageCount++; + } + if (trailingImageCount === selectionState.trailingImageCount) { + // Find which index the image is in its parent's children + var endIndex = 0; + while (node.parentNode.childNodes[endIndex] !== node) { + endIndex++; + } + range.setEnd(node.parentNode, endIndex + 1); + stop = true; + } + } + + if (!stop && node.nodeType === 1) { + // this is an element + // add all its children to the stack + var i = node.childNodes.length - 1; + while (i >= 0) { + nodeStack.push(node.childNodes[i]); + i -= 1; + } + } + } + + if (!stop) { + node = nodeStack.pop(); + } + } + + if (typeof selectionState.emptyBlocksIndex !== 'undefined') { + range = this.importSelectionMoveCursorPastBlocks(doc, root, selectionState.emptyBlocksIndex, range); + } + + // If the selection is right at the ending edge of a link, put it outside the anchor tag instead of inside. + if (favorLaterSelectionAnchor) { + range = this.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 && + MediumEditor.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; + } + } + range.setStart(currentNode.parentNode, currentNodeIndex + 1); + range.collapse(true); + } + } + return range; + }, + + // Uses the emptyBlocksIndex calculated by getIndexRelativeToAdjacentEmptyBlocks + // to move the cursor back to the start of the correct paragraph + importSelectionMoveCursorPastBlocks: function (doc, root, index, range) { + var treeWalker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, filterOnlyParentElements, false), + startContainer = range.startContainer, + startBlock, + targetNode, + currIndex = 0; + index = index || 1; // If index is 0, we still want to move to the next block + + // Chrome counts newlines and spaces that separate block elements as actual elements. + // If the selection is inside one of these text nodes, and it has a previous sibling + // which is a block element, we want the treewalker to start at the previous sibling + // and NOT at the parent of the textnode + if (startContainer.nodeType === 3 && MediumEditor.util.isBlockContainer(startContainer.previousSibling)) { + startBlock = startContainer.previousSibling; + } else { + startBlock = MediumEditor.util.getClosestBlockContainer(startContainer); + } + + // Skip over empty blocks until we hit the block we want the selection to be in + while (treeWalker.nextNode()) { + if (!targetNode) { + // Loop through all blocks until we hit the starting block element + if (startBlock === treeWalker.currentNode) { + targetNode = treeWalker.currentNode; + } + } else { + targetNode = treeWalker.currentNode; + currIndex++; + // We hit the target index, bail + if (currIndex === index) { + break; + } + // If we find a non-empty block, ignore the emptyBlocksIndex and just put selection here + if (targetNode.textContent.length > 0) { + break; + } + } + } + + // 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(MediumEditor.util.getFirstSelectableLeafNode(targetNode), 0); + + return range; + }, + + // Returns -1 unless the cursor is at the beginning of a paragraph/block + // If the paragraph/block is preceeded by empty paragraphs/block (with no text) + // it will return the number of empty paragraphs before the cursor. + // Otherwise, it will return 0, which indicates the cursor is at the beginning + // of a paragraph/block, and not at the end of the paragraph/block before it + 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.textContent.length > 0 && cursorOffset > 0) { + return -1; + } + + // 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[cursorOffset]; + } + if (node && !MediumEditor.util.isElementAtBeginningOfBlock(node)) { + return -1; + } + + // Walk over block elements, counting number of empty blocks between last piece of text + // and the block the cursor is in + var closestBlock = MediumEditor.util.getClosestBlockContainer(cursorContainer), + 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; + } + if (treeWalker.currentNode === closestBlock) { + return emptyBlocksCount; + } + if (!blockIsEmpty) { + emptyBlocksCount = 0; + } + } + + return emptyBlocksCount; + }, + + getTrailingImageCount: function (root, selectionState, endContainer, endOffset) { + var lastNode = endContainer.childNodes[endOffset - 1]; + while (lastNode.hasChildNodes()) { + lastNode = lastNode.lastChild; + } + + var node = root, + nodeStack = [], + charIndex = 0, + foundStart = false, + foundEnd = false, + stop = false, + nextCharIndex, + trailingImages = 0; + + while (!stop && node) { + // Only iterate over elements and text nodes + if (node.nodeType > 3) { + continue; + } + + if (node.nodeType === 3 && !foundEnd) { + trailingImages = 0; + nextCharIndex = charIndex + node.length; + if (!foundStart && selectionState.start >= charIndex && selectionState.start <= nextCharIndex) { + foundStart = true; + } + if (foundStart && selectionState.end >= charIndex && selectionState.end <= nextCharIndex) { + foundEnd = true; + } + charIndex = nextCharIndex; + } else { + if (node.nodeName.toLowerCase() === 'img') { + trailingImages++; + } + + if (node === lastNode) { + stop = true; + } else if (node.nodeType === 1) { + // this is an element + // add all its children to the stack + var i = node.childNodes.length - 1; + while (i >= 0) { + nodeStack.push(node.childNodes[i]); + i -= 1; + } + } + } + + if (!stop) { + node = nodeStack.pop(); + } + } + + return trailingImages; + }, + + // determine if the current selection contains any 'content' + // content being and non-white space text or an image + selectionContainsContent: function (doc) { + var sel = doc.getSelection(); + + // collapsed selection or selection withour range doesn't contain content + if (!sel || sel.isCollapsed || !sel.rangeCount) { + return false; + } + + // if toString() contains any text, the selection contains some content + if (sel.toString().trim() !== '') { + return true; + } + + // if selection contains only image(s), it will return empty for toString() + // so check for an image manually + var selectionNode = this.getSelectedParentElement(sel.getRangeAt(0)); + if (selectionNode) { + if (selectionNode.nodeName.toLowerCase() === 'img' || + (selectionNode.nodeType === 1 && selectionNode.querySelector('img'))) { + return true; + } + } + + return false; + }, + + 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()); + } + html = container.innerHTML; + } + return html; + }, + + /** + * 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 (!range) { + range = window.getSelection().getRangeAt(0); + } + + preCaretRange = range.cloneRange(); + postCaretRange = range.cloneRange(); + + preCaretRange.selectNodeContents(element); + preCaretRange.setEnd(range.endContainer, range.endOffset); + + postCaretRange.selectNodeContents(element); + postCaretRange.setStart(range.endContainer, range.endOffset); + + return { + left: preCaretRange.toString().length, + right: postCaretRange.toString().length + }; + }, + + // 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; + }, + + getSelectedParentElement: function (range) { + if (!range) { + return null; + } + + // 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; + } + + return toRet; + } + + return [].filter.call(range.commonAncestorContainer.getElementsByTagName('*'), function (el) { + return (typeof selection.containsNode === 'function') ? selection.containsNode(el, true) : true; + }); + }, + + 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; + }, + + /** + * 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); + }, + + getSelectionRange: function (ownerDocument) { + var selection = ownerDocument.getSelection(); + if (selection.rangeCount === 0) { + return null; + } + return selection.getRangeAt(0); + }, + + // 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; + } + }; + + MediumEditor.selection = Selection; +}()); + +(function () { + 'use strict'; + + var Events = function (instance) { + this.base = instance; + this.options = this.base.options; + this.events = []; + this.disabledEvents = {}; + this.customEvents = {}; + this.listeners = {}; + }; + + Events.prototype = { + InputEventOnContenteditableSupported: !MediumEditor.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; + }, + + detachAllDOMEvents: function () { + var e = this.events.pop(); + while (e) { + e[0].removeEventListener(e[1], e[2], e[3]); + e = this.events.pop(); + } + }, + + enableCustomEvent: function (event) { + if (this.disabledEvents[event] !== undefined) { + delete this.disabledEvents[event]; + } + }, + + disableCustomEvent: function (event) { + this.disabledEvents[event] = true; + }, + + // custom events + attachCustomEvent: function (event, listener) { + this.setupListener(event); + if (!this.customEvents[event]) { + this.customEvents[event] = []; + } + 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() + } + }, + + indexOfCustomListener: function (event, listener) { + if (!this.customEvents[event] || !this.customEvents[event].length) { + return -1; + } + + 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.disabledEvents[name]) { + this.customEvents[name].forEach(function (listener) { + listener(data, editable); + }); + } + }, + + // Cleaning up + + destroy: function () { + this.detachAllDOMEvents(); + this.detachAllCustomEvents(); + this.detachExecCommand(); + + if (this.base.elements) { + this.base.elements.forEach(function (element) { + element.removeAttribute('data-medium-focused'); + }); + } + }, + + // Listening to calls to document.execCommand + + // Attach a listener to be notified when document.execCommand is called + attachToExecCommand: function () { + if (this.execCommandListener) { + return; + } + + // 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); + }, + + // Remove our listener for calls to document.execCommand + detachExecCommand: function () { + var doc = this.options.ownerDocument; + if (!this.execCommandListener || !doc.execCommand.listeners) { + return; + } + + // 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); + } + + // 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; + } + + 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(); + } + 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 'editableKeydownSpace': + // Detecting keydown for SPACE on the contenteditables + this.setupListener('editableKeydown'); + 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 && + (MediumEditor.util.isDescendant(hadFocus, this.lastMousedownTarget, true) || + MediumEditor.util.isDescendant(toolbarEl, this.lastMousedownTarget, true) || + MediumEditor.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 && (MediumEditor.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 = !MediumEditor.util.isDescendant(hadFocus, target, true) && + !MediumEditor.util.isDescendant(toolbarEl, target, true) && + !MediumEditor.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); + } + } + + if (externalEvent) { + this.triggerCustomEvent('externalInteraction', eventObj); + } + }, + + updateInput: function (target, eventObj) { + if (!this.contentCache) { + return; + } + // 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; + }, + + 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 (MediumEditor.util.isDescendant(element, activeElement, true)) { + currentTarget = element; + return true; + } + return false; + }, this); + + // We know selectionchange fired within one of our contenteditables + if (currentTarget) { + this.updateInput(currentTarget, { target: activeElement, currentTarget: currentTarget }); + } + } + }, + + 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 }); + } + }, + + handleBodyClick: function (event) { + this.updateFocus(event.target, event); + }, + + handleBodyFocus: function (event) { + this.updateFocus(event.target, event); + }, + + handleBodyMousedown: function (event) { + this.lastMousedownTarget = event.target; + }, + + handleInput: function (event) { + this.updateInput(event.currentTarget, event); + }, + + handleClick: function (event) { + this.triggerCustomEvent('editableClick', event, event.currentTarget); + }, + + handleBlur: function (event) { + this.triggerCustomEvent('editableBlur', event, event.currentTarget); + }, + + handleKeypress: function (event) { + this.triggerCustomEvent('editableKeypress', event, event.currentTarget); + + // 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 }; + + // 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); + } + }, + + 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 (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.SPACE)) { + return this.triggerCustomEvent('editableKeydownSpace', event, event.currentTarget); + } + + if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER) || (event.ctrlKey && MediumEditor.util.isKey(event, MediumEditor.util.keyCode.M))) { + return this.triggerCustomEvent('editableKeydownEnter', event, event.currentTarget); + } + + if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.TAB)) { + return this.triggerCustomEvent('editableKeydownTab', event, event.currentTarget); + } + + if (MediumEditor.util.isKey(event, [MediumEditor.util.keyCode.DELETE, MediumEditor.util.keyCode.BACKSPACE])) { + return this.triggerCustomEvent('editableKeydownDelete', event, event.currentTarget); + } + } + }; + + MediumEditor.Events = Events; +}()); + +(function () { + 'use strict'; + + var Button = MediumEditor.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 or + * 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, + + // 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)) { + MediumEditor.Extension.call(this, this.defaults[options]); + } else { + MediumEditor.Extension.call(this, options); + } + }, + + init: function () { + MediumEditor.Extension.prototype.init.apply(this, arguments); + + this.button = this.createButton(); + this.on(this.button, 'click', this.handleClick.bind(this)); + }, + + /* 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; + }, + + getAction: function () { + return (typeof this.action === 'function') ? this.action(this.base.options) : this.action; + }, + + 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); + }); + } + + // 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; + }, + + handleClick: function (event) { + event.preventDefault(); + event.stopPropagation(); + + var action = this.getAction(); + + if (action) { + this.execAction(action); + } + }, + + isActive: function () { + return this.button.classList.contains(this.getEditorOption('activeButtonClass')); + }, + + setInactive: function () { + this.button.classList.remove(this.getEditorOption('activeButtonClass')); + delete this.knownState; + }, + + setActive: function () { + this.button.classList.add(this.getEditorOption('activeButtonClass')); + delete this.knownState; + }, + + queryCommandState: function () { + var queryState = null; + if (this.useQueryState) { + queryState = this.base.queryCommandState(this.getAction()); + } + return queryState; + }, + + isAlreadyApplied: function (node) { + var isMatch = false, + tagNames = this.getTagNames(), + styleVals, + computedStyle; + + 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; + } + } + }, this); + } + + return isMatch; + } + }); + + Button.isBuiltInButton = function (name) { + return (typeof name === 'string') && MediumEditor.extensions.button.prototype.defaults.hasOwnProperty(name); + }; + + MediumEditor.extensions.button = Button; +}()); + +(function () { + 'use strict'; + + /* MediumEditor.extensions.button.defaults: [Object] + * Set of default config options for all of the built-in MediumEditor buttons + */ + MediumEditor.extensions.button.prototype.defaults = { + 'bold': { + name: 'bold', + action: 'bold', + aria: 'bold', + tagNames: ['b', 'strong'], + style: { + prop: 'font-weight', + value: '700|bold' + }, + useQueryState: true, + contentDefault: 'B', + contentFA: '' + }, + 'italic': { + name: 'italic', + action: 'italic', + aria: 'italic', + tagNames: ['i', 'em'], + style: { + prop: 'font-style', + value: 'italic' + }, + useQueryState: true, + contentDefault: 'I', + contentFA: '' + }, + 'underline': { + name: 'underline', + action: 'underline', + aria: 'underline', + tagNames: ['u'], + style: { + prop: 'text-decoration', + value: 'underline' + }, + useQueryState: true, + contentDefault: 'U', + contentFA: '' + }, + 'strikethrough': { + name: 'strikethrough', + action: 'strikethrough', + aria: 'strike through', + tagNames: ['strike'], + style: { + prop: 'text-decoration', + value: 'line-through' + }, + useQueryState: true, + contentDefault: 'A', + contentFA: '' + }, + '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: 'x1', + contentFA: '' + }, + '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: 'x1', + contentFA: '' + }, + 'image': { + name: 'image', + action: 'image', + aria: 'image', + tagNames: ['img'], + contentDefault: 'image', + contentFA: '' + }, + 'orderedlist': { + name: 'orderedlist', + action: 'insertorderedlist', + aria: 'ordered list', + tagNames: ['ol'], + useQueryState: true, + contentDefault: '1.', + contentFA: '' + }, + 'unorderedlist': { + name: 'unorderedlist', + action: 'insertunorderedlist', + aria: 'unordered list', + tagNames: ['ul'], + useQueryState: true, + contentDefault: '•', + contentFA: '' + }, + 'indent': { + name: 'indent', + action: 'indent', + aria: 'indent', + tagNames: [], + contentDefault: '→', + contentFA: '' + }, + 'outdent': { + name: 'outdent', + action: 'outdent', + aria: 'outdent', + tagNames: [], + contentDefault: '←', + contentFA: '' + }, + 'justifyCenter': { + name: 'justifyCenter', + action: 'justifyCenter', + aria: 'center justify', + tagNames: [], + style: { + prop: 'text-align', + value: 'center' + }, + contentDefault: 'C', + contentFA: '' + }, + 'justifyFull': { + name: 'justifyFull', + action: 'justifyFull', + aria: 'full justify', + tagNames: [], + style: { + prop: 'text-align', + value: 'justify' + }, + contentDefault: 'J', + contentFA: '' + }, + 'justifyLeft': { + name: 'justifyLeft', + action: 'justifyLeft', + aria: 'left justify', + tagNames: [], + style: { + prop: 'text-align', + value: 'left' + }, + contentDefault: 'L', + contentFA: '' + }, + 'justifyRight': { + name: 'justifyRight', + action: 'justifyRight', + aria: 'right justify', + tagNames: [], + style: { + prop: 'text-align', + value: 'right' + }, + contentDefault: 'R', + contentFA: '' + }, + // Known inline elements that are not removed, or not removed consistantly across browsers: + // ,