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