astro-mail/public/assets/js/editor/medium-editor.js

7894 lines
307 KiB
JavaScript
Raw Normal View History

2024-11-14 10:24:23 +00:00
/*global self, document, DOMException */
/*! @source http://purl.eligrey.com/github/classList.js/blob/master/classList.js */
// Full polyfill for browsers with no classList support
if (!("classList" in document.createElement("_"))) {
(function (view) {
"use strict";
if (!('Element' in view)) return;
var
classListProp = "classList"
, protoProp = "prototype"
, elemCtrProto = view.Element[protoProp]
, objCtr = Object
, strTrim = String[protoProp].trim || function () {
return this.replace(/^\s+|\s+$/g, "");
}
, arrIndexOf = Array[protoProp].indexOf || function (item) {
var
i = 0
, len = this.length
;
for (; i < len; i++) {
if (i in this && this[i] === item) {
return i;
}
}
return -1;
}
// Vendors: please allow content code to instantiate DOMExceptions
, DOMEx = function (type, message) {
this.name = type;
this.code = DOMException[type];
this.message = message;
}
, checkTokenAndGetIndex = function (classList, token) {
if (token === "") {
throw new DOMEx(
"SYNTAX_ERR"
, "An invalid or illegal string was specified"
);
}
if (/\s/.test(token)) {
throw new DOMEx(
"INVALID_CHARACTER_ERR"
, "String contains an invalid character"
);
}
return arrIndexOf.call(classList, token);
}
, ClassList = function (elem) {
var
trimmedClasses = strTrim.call(elem.getAttribute("class") || "")
, classes = trimmedClasses ? trimmedClasses.split(/\s+/) : []
, i = 0
, len = classes.length
;
for (; i < len; i++) {
this.push(classes[i]);
}
this._updateClassName = function () {
elem.setAttribute("class", this.toString());
};
}
, classListProto = ClassList[protoProp] = []
, classListGetter = function () {
return new ClassList(this);
}
;
// Most DOMException implementations don't allow calling DOMException's toString()
// on non-DOMExceptions. Error's toString() is sufficient here.
DOMEx[protoProp] = Error[protoProp];
classListProto.item = function (i) {
return this[i] || null;
};
classListProto.contains = function (token) {
token += "";
return checkTokenAndGetIndex(this, token) !== -1;
};
classListProto.add = function () {
var
tokens = arguments
, i = 0
, l = tokens.length
, token
, updated = false
;
do {
token = tokens[i] + "";
if (checkTokenAndGetIndex(this, token) === -1) {
this.push(token);
updated = true;
}
}
while (++i < l);
if (updated) {
this._updateClassName();
}
};
classListProto.remove = function () {
var
tokens = arguments
, i = 0
, l = tokens.length
, token
, updated = false
, index
;
do {
token = tokens[i] + "";
index = checkTokenAndGetIndex(this, token);
while (index !== -1) {
this.splice(index, 1);
updated = true;
index = checkTokenAndGetIndex(this, token);
}
}
while (++i < l);
if (updated) {
this._updateClassName();
}
};
classListProto.toggle = function (token, force) {
token += "";
var
result = this.contains(token)
, method = result ?
force !== true && "remove"
:
force !== false && "add"
;
if (method) {
this[method](token);
}
if (force === true || force === false) {
return force;
} else {
return !result;
}
};
classListProto.toString = function () {
return this.join(" ");
};
if (objCtr.defineProperty) {
var classListPropDesc = {
get: classListGetter
, enumerable: true
, configurable: true
};
try {
objCtr.defineProperty(elemCtrProto, classListProp, classListPropDesc);
} catch (ex) { // IE 8 doesn't support enumerable:true
if (ex.number === -0x7FF5EC54) {
classListPropDesc.enumerable = false;
objCtr.defineProperty(elemCtrProto, classListProp, classListPropDesc);
}
}
} else if (objCtr[protoProp].__defineGetter__) {
elemCtrProto.__defineGetter__(classListProp, classListGetter);
}
}(self));
}
/* Blob.js
* A Blob implementation.
* 2014-07-24
*
* By Eli Grey, http://eligrey.com
* By Devin Samarin, https://github.com/dsamarin
* License: X11/MIT
* See https://github.com/eligrey/Blob.js/blob/master/LICENSE.md
*/
/*global self, unescape */
/*jslint bitwise: true, regexp: true, confusion: true, es5: true, vars: true, white: true,
plusplus: true */
/*! @source http://purl.eligrey.com/github/Blob.js/blob/master/Blob.js */
(function (view) {
"use strict";
view.URL = view.URL || view.webkitURL;
if (view.Blob && view.URL) {
try {
new Blob;
return;
} catch (e) {}
}
// Internally we use a BlobBuilder implementation to base Blob off of
// in order to support older browsers that only have BlobBuilder
var BlobBuilder = view.BlobBuilder || view.WebKitBlobBuilder || view.MozBlobBuilder || (function(view) {
var
get_class = function(object) {
return Object.prototype.toString.call(object).match(/^\[object\s(.*)\]$/)[1];
}
, FakeBlobBuilder = function BlobBuilder() {
this.data = [];
}
, FakeBlob = function Blob(data, type, encoding) {
this.data = data;
this.size = data.length;
this.type = type;
this.encoding = encoding;
}
, FBB_proto = FakeBlobBuilder.prototype
, FB_proto = FakeBlob.prototype
, FileReaderSync = view.FileReaderSync
, FileException = function(type) {
this.code = this[this.name = type];
}
, file_ex_codes = (
"NOT_FOUND_ERR SECURITY_ERR ABORT_ERR NOT_READABLE_ERR ENCODING_ERR "
+ "NO_MODIFICATION_ALLOWED_ERR INVALID_STATE_ERR SYNTAX_ERR"
).split(" ")
, file_ex_code = file_ex_codes.length
, real_URL = view.URL || view.webkitURL || view
, real_create_object_URL = real_URL.createObjectURL
, real_revoke_object_URL = real_URL.revokeObjectURL
, URL = real_URL
, btoa = view.btoa
, atob = view.atob
, ArrayBuffer = view.ArrayBuffer
, Uint8Array = view.Uint8Array
, origin = /^[\w-]+:\/*\[?[\w\.:-]+\]?(?::[0-9]+)?/
;
FakeBlob.fake = FB_proto.fake = true;
while (file_ex_code--) {
FileException.prototype[file_ex_codes[file_ex_code]] = file_ex_code + 1;
}
// Polyfill URL
if (!real_URL.createObjectURL) {
URL = view.URL = function(uri) {
var
uri_info = document.createElementNS("http://www.w3.org/1999/xhtml", "a")
, uri_origin
;
uri_info.href = uri;
if (!("origin" in uri_info)) {
if (uri_info.protocol.toLowerCase() === "data:") {
uri_info.origin = null;
} else {
uri_origin = uri.match(origin);
uri_info.origin = uri_origin && uri_origin[1];
}
}
return uri_info;
};
}
URL.createObjectURL = function(blob) {
var
type = blob.type
, data_URI_header
;
if (type === null) {
type = "application/octet-stream";
}
if (blob instanceof FakeBlob) {
data_URI_header = "data:" + type;
if (blob.encoding === "base64") {
return data_URI_header + ";base64," + blob.data;
} else if (blob.encoding === "URI") {
return data_URI_header + "," + decodeURIComponent(blob.data);
} if (btoa) {
return data_URI_header + ";base64," + btoa(blob.data);
} else {
return data_URI_header + "," + encodeURIComponent(blob.data);
}
} else if (real_create_object_URL) {
return real_create_object_URL.call(real_URL, blob);
}
};
URL.revokeObjectURL = function(object_URL) {
if (object_URL.substring(0, 5) !== "data:" && real_revoke_object_URL) {
real_revoke_object_URL.call(real_URL, object_URL);
}
};
FBB_proto.append = function(data/*, endings*/) {
var bb = this.data;
// decode data to a binary string
if (Uint8Array && (data instanceof ArrayBuffer || data instanceof Uint8Array)) {
var
str = ""
, buf = new Uint8Array(data)
, i = 0
, buf_len = buf.length
;
for (; i < buf_len; i++) {
str += String.fromCharCode(buf[i]);
}
bb.push(str);
} else if (get_class(data) === "Blob" || get_class(data) === "File") {
if (FileReaderSync) {
var fr = new FileReaderSync;
bb.push(fr.readAsBinaryString(data));
} else {
// async FileReader won't work as BlobBuilder is sync
throw new FileException("NOT_READABLE_ERR");
}
} else if (data instanceof FakeBlob) {
if (data.encoding === "base64" && atob) {
bb.push(atob(data.data));
} else if (data.encoding === "URI") {
bb.push(decodeURIComponent(data.data));
} else if (data.encoding === "raw") {
bb.push(data.data);
}
} else {
if (typeof data !== "string") {
data += ""; // convert unsupported types to strings
}
// decode UTF-16 to binary string
bb.push(unescape(encodeURIComponent(data)));
}
};
FBB_proto.getBlob = function(type) {
if (!arguments.length) {
type = null;
}
return new FakeBlob(this.data.join(""), type, "raw");
};
FBB_proto.toString = function() {
return "[object BlobBuilder]";
};
FB_proto.slice = function(start, end, type) {
var args = arguments.length;
if (args < 3) {
type = null;
}
return new FakeBlob(
this.data.slice(start, args > 1 ? end : this.data.length)
, type
, this.encoding
);
};
FB_proto.toString = function() {
return "[object Blob]";
};
FB_proto.close = function() {
this.size = 0;
delete this.data;
};
return FakeBlobBuilder;
}(view));
view.Blob = function(blobParts, options) {
var type = options ? (options.type || "") : "";
var builder = new BlobBuilder();
if (blobParts) {
for (var i = 0, len = blobParts.length; i < len; i++) {
if (Uint8Array && blobParts[i] instanceof Uint8Array) {
builder.append(blobParts[i].buffer);
}
else {
builder.append(blobParts[i]);
}
}
}
var blob = builder.getBlob(type);
if (!blob.slice && blob.webkitSlice) {
blob.slice = blob.webkitSlice;
}
return blob;
};
var getPrototypeOf = Object.getPrototypeOf || function(object) {
return object.__proto__;
};
view.Blob.prototype = getPrototypeOf(new view.Blob());
}(typeof self !== "undefined" && self || typeof window !== "undefined" && window || this.content || this));
(function (root, factory) {
'use strict';
var isElectron = typeof module === 'object' && typeof process !== 'undefined' && process && process.versions && process.versions.electron;
if (!isElectron && typeof module === 'object') {
module.exports = factory;
} else if (typeof define === 'function' && define.amd) {
define(function () {
return factory;
});
} else {
root.MediumEditor = factory;
}
}(this, function () {
'use strict';
function MediumEditor(elements, options) {
'use strict';
return this.init(elements, options);
}
MediumEditor.extensions = {};
/*jshint unused: true */
(function (window) {
'use strict';
function copyInto(overwrite, dest) {
var prop,
sources = Array.prototype.slice.call(arguments, 2);
dest = dest || {};
for (var i = 0; i < sources.length; i++) {
var source = sources[i];
if (source) {
for (prop in source) {
if (source.hasOwnProperty(prop) &&
typeof source[prop] !== 'undefined' &&
(overwrite || dest.hasOwnProperty(prop) === false)) {
dest[prop] = source[prop];
}
}
}
}
return dest;
}
// https://developer.mozilla.org/en-US/docs/Web/API/Node/contains
// Some browsers (including phantom) don't return true for Node.contains(child)
// if child is a text node. Detect these cases here and use a fallback
// for calls to Util.isDescendant()
var nodeContainsWorksWithTextNodes = false;
try {
var testParent = document.createElement('div'),
testText = document.createTextNode(' ');
testParent.appendChild(testText);
nodeContainsWorksWithTextNodes = testParent.contains(testText);
} catch (exc) {}
var Util = {
// http://stackoverflow.com/questions/17907445/how-to-detect-ie11#comment30165888_17907562
// by rg89
isIE: ((navigator.appName === 'Microsoft Internet Explorer') || ((navigator.appName === 'Netscape') && (new RegExp('Trident/.*rv:([0-9]{1,}[.0-9]{0,})').exec(navigator.userAgent) !== null))),
isEdge: (/Edge\/\d+/).exec(navigator.userAgent) !== null,
// if firefox
isFF: (navigator.userAgent.toLowerCase().indexOf('firefox') > -1),
// http://stackoverflow.com/a/11752084/569101
isMac: (window.navigator.platform.toUpperCase().indexOf('MAC') >= 0),
// https://github.com/jashkenas/underscore
// Lonely letter MUST USE the uppercase code
keyCode: {
BACKSPACE: 8,
TAB: 9,
ENTER: 13,
ESCAPE: 27,
SPACE: 32,
DELETE: 46,
K: 75, // K keycode, and not k
M: 77,
V: 86
},
/**
* Returns true if it's metaKey on Mac, or ctrlKey on non-Mac.
* See #591
*/
isMetaCtrlKey: function (event) {
if ((Util.isMac && event.metaKey) || (!Util.isMac && event.ctrlKey)) {
return true;
}
return false;
},
/**
* Returns true if the key associated to the event is inside keys array
*
* @see : https://github.com/jquery/jquery/blob/0705be475092aede1eddae01319ec931fb9c65fc/src/event.js#L473-L484
* @see : http://stackoverflow.com/q/4471582/569101
*/
isKey: function (event, keys) {
var keyCode = Util.getKeyCode(event);
// it's not an array let's just compare strings!
if (false === Array.isArray(keys)) {
return keyCode === keys;
}
if (-1 === keys.indexOf(keyCode)) {
return false;
}
return true;
},
getKeyCode: function (event) {
var keyCode = event.which;
// getting the key code from event
if (null === keyCode) {
keyCode = event.charCode !== null ? event.charCode : event.keyCode;
}
return keyCode;
},
blockContainerElementNames: [
// elements our editor generates
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'ul', 'li', 'ol',
// all other known block elements
'address', 'article', 'aside', 'audio', 'canvas', 'dd', 'dl', 'dt', 'fieldset',
'figcaption', 'figure', 'footer', 'form', 'header', 'hgroup', 'main', 'nav',
'noscript', 'output', 'section', 'video',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td'
],
emptyElementNames: ['br', 'col', 'colgroup', 'hr', 'img', 'input', 'source', 'wbr'],
extend: function extend(/* dest, source1, source2, ...*/) {
var args = [true].concat(Array.prototype.slice.call(arguments));
return copyInto.apply(this, args);
},
defaults: function defaults(/*dest, source1, source2, ...*/) {
var args = [false].concat(Array.prototype.slice.call(arguments));
return copyInto.apply(this, args);
},
/*
* Create a link around the provided text nodes which must be adjacent to each other and all be
* descendants of the same closest block container. If the preconditions are not met, unexpected
* behavior will result.
*/
createLink: function (document, textNodes, href, target) {
var anchor = document.createElement('a');
Util.moveTextRangeIntoElement(textNodes[0], textNodes[textNodes.length - 1], anchor);
anchor.setAttribute('href', href);
if (target) {
if (target === '_blank') {
anchor.setAttribute('rel', 'noopener noreferrer');
}
anchor.setAttribute('target', target);
}
return anchor;
},
/*
* Given the provided match in the format {start: 1, end: 2} where start and end are indices into the
* textContent of the provided element argument, modify the DOM inside element to ensure that the text
* identified by the provided match can be returned as text nodes that contain exactly that text, without
* any additional text at the beginning or end of the returned array of adjacent text nodes.
*
* The only DOM manipulation performed by this function is splitting the text nodes, non-text nodes are
* not affected in any way.
*/
findOrCreateMatchingTextNodes: function (document, element, match) {
var treeWalker = document.createTreeWalker(element, NodeFilter.SHOW_ALL, null, false),
matchedNodes = [],
currentTextIndex = 0,
startReached = false,
currentNode = null,
newNode = null;
while ((currentNode = treeWalker.nextNode()) !== null) {
if (currentNode.nodeType > 3) {
continue;
} else if (currentNode.nodeType === 3) {
if (!startReached && match.start < (currentTextIndex + currentNode.nodeValue.length)) {
startReached = true;
newNode = Util.splitStartNodeIfNeeded(currentNode, match.start, currentTextIndex);
}
if (startReached) {
Util.splitEndNodeIfNeeded(currentNode, newNode, match.end, currentTextIndex);
}
if (startReached && currentTextIndex === match.end) {
break; // Found the node(s) corresponding to the link. Break out and move on to the next.
} else if (startReached && currentTextIndex > (match.end + 1)) {
throw new Error('PerformLinking overshot the target!'); // should never happen...
}
if (startReached) {
matchedNodes.push(newNode || currentNode);
}
currentTextIndex += currentNode.nodeValue.length;
if (newNode !== null) {
currentTextIndex += newNode.nodeValue.length;
// Skip the newNode as we'll already have pushed it to the matches
treeWalker.nextNode();
}
newNode = null;
} else if (currentNode.tagName.toLowerCase() === 'img') {
if (!startReached && (match.start <= currentTextIndex)) {
startReached = true;
}
if (startReached) {
matchedNodes.push(currentNode);
}
}
}
return matchedNodes;
},
/*
* Given the provided text node and text coordinates, split the text node if needed to make it align
* precisely with the coordinates.
*
* This function is intended to be called from Util.findOrCreateMatchingTextNodes.
*/
splitStartNodeIfNeeded: function (currentNode, matchStartIndex, currentTextIndex) {
if (matchStartIndex !== currentTextIndex) {
return currentNode.splitText(matchStartIndex - currentTextIndex);
}
return null;
},
/*
* Given the provided text node and text coordinates, split the text node if needed to make it align
* precisely with the coordinates. The newNode argument should from the result of Util.splitStartNodeIfNeeded,
* if that function has been called on the same currentNode.
*
* This function is intended to be called from Util.findOrCreateMatchingTextNodes.
*/
splitEndNodeIfNeeded: function (currentNode, newNode, matchEndIndex, currentTextIndex) {
var textIndexOfEndOfFarthestNode,
endSplitPoint;
textIndexOfEndOfFarthestNode = currentTextIndex + currentNode.nodeValue.length +
(newNode ? newNode.nodeValue.length : 0) - 1;
endSplitPoint = matchEndIndex - currentTextIndex -
(newNode ? currentNode.nodeValue.length : 0);
if (textIndexOfEndOfFarthestNode >= matchEndIndex &&
currentTextIndex !== textIndexOfEndOfFarthestNode &&
endSplitPoint !== 0) {
(newNode || currentNode).splitText(endSplitPoint);
}
},
/*
* Take an element, and break up all of its text content into unique pieces such that:
* 1) All text content of the elements are in separate blocks. No piece of text content should span
* across multiple blocks. This means no element return by this function should have
* any blocks as children.
* 2) The union of the textcontent of all of the elements returned here covers all
* of the text within the element.
*
*
* EXAMPLE:
* In the event that we have something like:
*
* <blockquote>
* <p>Some Text</p>
* <ol>
* <li>List Item 1</li>
* <li>List Item 2</li>
* </ol>
* </blockquote>
*
* This function would return these elements as an array:
* [ <p>Some Text</p>, <li>List Item 1</li>, <li>List Item 2</li> ]
*
* Since the <blockquote> and <ol> elements contain blocks within them they are not returned.
* Since the <p> and <li>'s don't contain block elements and cover all the text content of the
* <blockquote> container, they are the elements returned.
*/
splitByBlockElements: function (element) {
if (element.nodeType !== 3 && element.nodeType !== 1) {
return [];
}
var toRet = [],
blockElementQuery = MediumEditor.util.blockContainerElementNames.join(',');
if (element.nodeType === 3 || element.querySelectorAll(blockElementQuery).length === 0) {
return [element];
}
for (var i = 0; i < element.childNodes.length; i++) {
var child = element.childNodes[i];
if (child.nodeType === 3) {
toRet.push(child);
} else if (child.nodeType === 1) {
var blockElements = child.querySelectorAll(blockElementQuery);
if (blockElements.length === 0) {
toRet.push(child);
} else {
toRet = toRet.concat(MediumEditor.util.splitByBlockElements(child));
}
}
}
return toRet;
},
// Find the next node in the DOM tree that represents any text that is being
// displayed directly next to the targetNode (passed as an argument)
// Text that appears directly next to the current node can be:
// - A sibling text node
// - A descendant of a sibling element
// - A sibling text node of an ancestor
// - A descendant of a sibling element of an ancestor
findAdjacentTextNodeWithContent: function findAdjacentTextNodeWithContent(rootNode, targetNode, ownerDocument) {
var pastTarget = false,
nextNode,
nodeIterator = ownerDocument.createNodeIterator(rootNode, NodeFilter.SHOW_TEXT, null, false);
// Use a native NodeIterator to iterate over all the text nodes that are descendants
// of the rootNode. Once past the targetNode, choose the first non-empty text node
nextNode = nodeIterator.nextNode();
while (nextNode) {
if (nextNode === targetNode) {
pastTarget = true;
} else if (pastTarget) {
if (nextNode.nodeType === 3 && nextNode.nodeValue && nextNode.nodeValue.trim().length > 0) {
break;
}
}
nextNode = nodeIterator.nextNode();
}
return nextNode;
},
// Find an element's previous sibling within a medium-editor element
// If one doesn't exist, find the closest ancestor's previous sibling
findPreviousSibling: function (node) {
if (!node || Util.isMediumEditorElement(node)) {
return false;
}
var previousSibling = node.previousSibling;
while (!previousSibling && !Util.isMediumEditorElement(node.parentNode)) {
node = node.parentNode;
previousSibling = node.previousSibling;
}
return previousSibling;
},
isDescendant: function isDescendant(parent, child, checkEquality) {
if (!parent || !child) {
return false;
}
if (parent === child) {
return !!checkEquality;
}
// If parent is not an element, it can't have any descendants
if (parent.nodeType !== 1) {
return false;
}
if (nodeContainsWorksWithTextNodes || child.nodeType !== 3) {
return parent.contains(child);
}
var node = child.parentNode;
while (node !== null) {
if (node === parent) {
return true;
}
node = node.parentNode;
}
return false;
},
// https://github.com/jashkenas/underscore
isElement: function isElement(obj) {
return !!(obj && obj.nodeType === 1);
},
// https://github.com/jashkenas/underscore
throttle: function (func, wait) {
var THROTTLE_INTERVAL = 50,
context,
args,
result,
timeout = null,
previous = 0,
later = function () {
previous = Date.now();
timeout = null;
result = func.apply(context, args);
if (!timeout) {
context = args = null;
}
};
if (!wait && wait !== 0) {
wait = THROTTLE_INTERVAL;
}
return function () {
var now = Date.now(),
remaining = wait - (now - previous);
context = this;
args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) {
context = args = null;
}
} else if (!timeout) {
timeout = setTimeout(later, remaining);
}
return result;
};
},
traverseUp: function (current, testElementFunction) {
if (!current) {
return false;
}
do {
if (current.nodeType === 1) {
if (testElementFunction(current)) {
return current;
}
// do not traverse upwards past the nearest containing editor
if (Util.isMediumEditorElement(current)) {
return false;
}
}
current = current.parentNode;
} while (current);
return false;
},
htmlEntities: function (str) {
// converts special characters (like <) into their escaped/encoded values (like &lt;).
// This allows you to show to display the string without the browser reading it as HTML.
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
},
// http://stackoverflow.com/questions/6690752/insert-html-at-caret-in-a-contenteditable-div
insertHTMLCommand: function (doc, html) {
var selection, range, el, fragment, node, lastNode, toReplace,
res = false,
ecArgs = ['insertHTML', false, html];
/* Edge's implementation of insertHTML is just buggy right now:
* - Doesn't allow leading white space at the beginning of an element
* - Found a case when a <font size="2"> tag was inserted when calling alignCenter inside a blockquote
*
* There are likely other bugs, these are just the ones we found so far.
* For now, let's just use the same fallback we did for IE
*/
if (!MediumEditor.util.isEdge && doc.queryCommandSupported('insertHTML')) {
try {
return doc.execCommand.apply(doc, ecArgs);
} catch (ignore) {}
}
selection = doc.getSelection();
if (selection.rangeCount) {
range = selection.getRangeAt(0);
toReplace = range.commonAncestorContainer;
// https://github.com/yabwe/medium-editor/issues/748
// If the selection is an empty editor element, create a temporary text node inside of the editor
// and select it so that we don't delete the editor element
if (Util.isMediumEditorElement(toReplace) && !toReplace.firstChild) {
range.selectNode(toReplace.appendChild(doc.createTextNode('')));
} else if ((toReplace.nodeType === 3 && range.startOffset === 0 && range.endOffset === toReplace.nodeValue.length) ||
(toReplace.nodeType !== 3 && toReplace.innerHTML === range.toString())) {
// Ensure range covers maximum amount of nodes as possible
// By moving up the DOM and selecting ancestors whose only child is the range
while (!Util.isMediumEditorElement(toReplace) &&
toReplace.parentNode &&
toReplace.parentNode.childNodes.length === 1 &&
!Util.isMediumEditorElement(toReplace.parentNode)) {
toReplace = toReplace.parentNode;
}
range.selectNode(toReplace);
}
range.deleteContents();
el = doc.createElement('div');
el.innerHTML = html;
fragment = doc.createDocumentFragment();
while (el.firstChild) {
node = el.firstChild;
lastNode = fragment.appendChild(node);
}
range.insertNode(fragment);
// Preserve the selection:
if (lastNode) {
range = range.cloneRange();
range.setStartAfter(lastNode);
range.collapse(true);
MediumEditor.selection.selectRange(doc, range);
}
res = true;
}
// https://github.com/yabwe/medium-editor/issues/992
// If we're monitoring calls to execCommand, notify listeners as if a real call had happened
if (doc.execCommand.callListeners) {
doc.execCommand.callListeners(ecArgs, res);
}
return res;
},
execFormatBlock: function (doc, tagName) {
// Get the top level block element that contains the selection
var blockContainer = Util.getTopBlockContainer(MediumEditor.selection.getSelectionStart(doc)),
childNodes;
// Special handling for blockquote
if (tagName === 'blockquote') {
if (blockContainer) {
childNodes = Array.prototype.slice.call(blockContainer.childNodes);
// Check if the blockquote has a block element as a child (nested blocks)
if (childNodes.some(function (childNode) {
return Util.isBlockContainer(childNode);
})) {
// FF handles blockquote differently on formatBlock
// allowing nesting, we need to use outdent
// https://developer.mozilla.org/en-US/docs/Rich-Text_Editing_in_Mozilla
return doc.execCommand('outdent', false, null);
}
}
// When IE blockquote needs to be called as indent
// http://stackoverflow.com/questions/1816223/rich-text-editor-with-blockquote-function/1821777#1821777
if (Util.isIE) {
return doc.execCommand('indent', false, tagName);
}
}
// If the blockContainer is already the element type being passed in
// treat it as 'undo' formatting and just convert it to a <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 (Util.isIE) {
tagName = '<' + tagName + '>';
}
// When FF, IE and Edge, we have to handle blockquote node seperately as 'formatblock' does not work.
// https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand#Commands
if (blockContainer && blockContainer.nodeName.toLowerCase() === 'blockquote') {
// For IE, just use outdent
if (Util.isIE && tagName === '<p>') {
return doc.execCommand('outdent', false, tagName);
}
// For Firefox and Edge, make sure there's a nested block element before calling outdent
if ((Util.isFF || Util.isEdge) && tagName === 'p') {
childNodes = Array.prototype.slice.call(blockContainer.childNodes);
// If there are some non-block elements we need to wrap everything in a <p> before we outdent
if (childNodes.some(function (childNode) {
return !Util.isBlockContainer(childNode);
})) {
doc.execCommand('formatBlock', false, tagName);
}
return doc.execCommand('outdent', false, tagName);
}
}
return doc.execCommand('formatBlock', false, tagName);
},
/**
* Set target to blank on the given el element
*
* TODO: not sure if this should be here
*
* When creating a link (using core -> createLink) the selection returned by Firefox will be the parent of the created link
* instead of the created link itself (as it is for Chrome for example), so we retrieve all "a" children to grab the good one by
* using `anchorUrl` to ensure that we are adding target="_blank" on the good one.
* This isn't a bulletproof solution anyway ..
*/
setTargetBlank: function (el, anchorUrl) {
var i, url = anchorUrl || false;
if (el.nodeName.toLowerCase() === 'a') {
el.target = '_blank';
el.rel = 'noopener noreferrer';
} 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';
el[i].rel = 'noopener noreferrer';
}
}
}
},
/*
* this function is called to explicitly remove the target='_blank' as FF holds on to _blank value even
* after unchecking the checkbox on anchor form
*/
removeTargetBlank: function (el, anchorUrl) {
var i;
if (el.nodeName.toLowerCase() === 'a') {
el.removeAttribute('target');
el.removeAttribute('rel');
} else {
el = el.getElementsByTagName('a');
for (i = 0; i < el.length; i += 1) {
if (anchorUrl === el[i].attributes.href.value) {
el[i].removeAttribute('target');
el[i].removeAttribute('rel');
}
}
}
},
/*
* this function adds one or several classes on an a element.
* if el parameter is not an a, it will look for a children of el.
* if no a children are found, it will look for the a parent.
*/
addClassToAnchors: function (el, buttonClass) {
var classes = buttonClass.split(' '),
i,
j;
if (el.nodeName.toLowerCase() === 'a') {
for (j = 0; j < classes.length; j += 1) {
el.classList.add(classes[j]);
}
} else {
var aChildren = el.getElementsByTagName('a');
if (aChildren.length === 0) {
var parentAnchor = Util.getClosestTag(el, 'a');
el = parentAnchor ? [parentAnchor] : [];
} else {
el = aChildren;
}
for (i = 0; i < el.length; i += 1) {
for (j = 0; j < classes.length; j += 1) {
el[i].classList.add(classes[j]);
}
}
}
},
isListItem: function (node) {
if (!node) {
return false;
}
if (node.nodeName.toLowerCase() === 'li') {
return true;
}
var parentNode = node.parentNode,
tagName = parentNode.nodeName.toLowerCase();
while (tagName === 'li' || (!Util.isBlockContainer(parentNode) && tagName !== 'div')) {
if (tagName === 'li') {
return true;
}
parentNode = parentNode.parentNode;
if (parentNode) {
tagName = parentNode.nodeName.toLowerCase();
} else {
return false;
}
}
return false;
},
cleanListDOM: function (ownerDocument, element) {
if (element.nodeName.toLowerCase() !== 'li') {
return;
}
var list = element.parentElement;
if (list.parentElement.nodeName.toLowerCase() === 'p') { // yes we need to clean up
Util.unwrap(list.parentElement, ownerDocument);
// move cursor at the end of the text inside the list
// for some unknown reason, the cursor is moved to end of the "visual" line
MediumEditor.selection.moveCursor(ownerDocument, element.firstChild, element.firstChild.textContent.length);
}
},
/* splitDOMTree
*
* Given a root element some descendant element, split the root element
* into its own element containing the descendant element and all elements
* on the left or right side of the descendant ('right' is default)
*
* example:
*
* <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;
}
}
createdNode = newParent;
while (targetNode) {
var sibling = targetNode.nextSibling;
// Special handling for the 'splitNode'
if (targetNode === splitOnNode) {
if (!targetNode.hasChildNodes()) {
targetNode.parentNode.removeChild(targetNode);
} else {
// For the node we're splitting on, if it has children, we need to clone it
// and not just move it
targetNode = targetNode.cloneNode(false);
}
// If the resulting split node has content, add it
if (targetNode.textContent) {
createdNode.appendChild(targetNode);
}
targetNode = (splitRight ? sibling : null);
} else {
// For general case, just remove the element and only
// add it to the split tree if it contains something
targetNode.parentNode.removeChild(targetNode);
if (targetNode.hasChildNodes() || targetNode.textContent) {
createdNode.appendChild(targetNode);
}
targetNode = sibling;
}
}
// If we had an element we wanted to append at the end, do that now
if (appendLast) {
createdNode.appendChild(appendLast);
}
splitOnNode = currParent;
}
return createdNode;
},
moveTextRangeIntoElement: function (startNode, endNode, newElement) {
if (!startNode || !endNode) {
return false;
}
var rootNode = Util.findCommonRoot(startNode, endNode);
if (!rootNode) {
return false;
}
if (endNode === startNode) {
var temp = startNode.parentNode,
sibling = startNode.nextSibling;
temp.removeChild(startNode);
newElement.appendChild(startNode);
if (sibling) {
temp.insertBefore(newElement, sibling);
} else {
temp.appendChild(newElement);
}
return newElement.hasChildNodes();
}
// create rootChildren array which includes all the children
// we care about
var rootChildren = [],
firstChild,
lastChild,
nextNode;
for (var i = 0; i < rootNode.childNodes.length; i++) {
nextNode = rootNode.childNodes[i];
if (!firstChild) {
if (Util.isDescendant(nextNode, startNode, true)) {
firstChild = nextNode;
}
} else {
if (Util.isDescendant(nextNode, endNode, true)) {
lastChild = nextNode;
break;
} else {
rootChildren.push(nextNode);
}
}
}
var afterLast = lastChild.nextSibling,
fragment = rootNode.ownerDocument.createDocumentFragment();
// build up fragment on startNode side of tree
if (firstChild === startNode) {
firstChild.parentNode.removeChild(firstChild);
fragment.appendChild(firstChild);
} else {
fragment.appendChild(Util.splitOffDOMTree(firstChild, startNode));
}
// add any elements between firstChild & lastChild
rootChildren.forEach(function (element) {
element.parentNode.removeChild(element);
fragment.appendChild(element);
});
// build up fragment on endNode side of the tree
if (lastChild === endNode) {
lastChild.parentNode.removeChild(lastChild);
fragment.appendChild(lastChild);
} else {
fragment.appendChild(Util.splitOffDOMTree(lastChild, endNode, true));
}
// Add fragment into passed in element
newElement.appendChild(fragment);
if (lastChild.parentNode === rootNode) {
// If last child is in the root, insert newElement in front of it
rootNode.insertBefore(newElement, lastChild);
} else if (afterLast) {
// If last child was removed, but it had a sibling, insert in front of it
rootNode.insertBefore(newElement, afterLast);
} else {
// lastChild was removed and was the last actual element just append
rootNode.appendChild(newElement);
}
return newElement.hasChildNodes();
},
/* based on http://stackoverflow.com/a/6183069 */
depthOfNode: function (inNode) {
var theDepth = 0,
node = inNode;
while (node.parentNode !== null) {
node = node.parentNode;
theDepth++;
}
return theDepth;
},
findCommonRoot: function (inNode1, inNode2) {
var depth1 = Util.depthOfNode(inNode1),
depth2 = Util.depthOfNode(inNode2),
node1 = inNode1,
node2 = inNode2;
while (depth1 !== depth2) {
if (depth1 > depth2) {
node1 = node1.parentNode;
depth1 -= 1;
} else {
node2 = node2.parentNode;
depth2 -= 1;
}
}
while (node1 !== node2) {
node1 = node1.parentNode;
node2 = node2.parentNode;
}
return node1;
},
/* END - based on http://stackoverflow.com/a/6183069 */
isElementAtBeginningOfBlock: function (node) {
var textVal,
sibling;
while (!Util.isBlockContainer(node) && !Util.isMediumEditorElement(node)) {
sibling = node;
while (sibling = sibling.previousSibling) {
textVal = sibling.nodeType === 3 ? sibling.nodeValue : sibling.textContent;
if (textVal.length > 0) {
return false;
}
}
node = node.parentNode;
}
return true;
},
isMediumEditorElement: function (element) {
return element && element.getAttribute && !!element.getAttribute('data-medium-editor-element');
},
getContainerEditorElement: function (element) {
return Util.traverseUp(element, function (node) {
return Util.isMediumEditorElement(node);
});
},
isBlockContainer: function (element) {
return element && element.nodeType !== 3 && Util.blockContainerElementNames.indexOf(element.nodeName.toLowerCase()) !== -1;
},
/* Finds the closest ancestor which is a block container element
* If element is within editor element but not within any other block element,
* the editor element is returned
*/
getClosestBlockContainer: function (node) {
return Util.traverseUp(node, function (node) {
return Util.isBlockContainer(node) || Util.isMediumEditorElement(node);
});
},
/* Finds highest level ancestor element which is a block container element
* If element is within editor element but not within any other block element,
* the editor element is returned
*/
getTopBlockContainer: function (element) {
var topBlock = Util.isBlockContainer(element) ? element : false;
Util.traverseUp(element, function (el) {
if (Util.isBlockContainer(el)) {
topBlock = el;
}
if (!topBlock && Util.isMediumEditorElement(el)) {
topBlock = el;
return true;
}
return false;
});
return topBlock;
},
getFirstSelectableLeafNode: function (element) {
while (element && element.firstChild) {
element = element.firstChild;
}
// We don't want to set the selection to an element that can't have children, this messes up Gecko.
element = Util.traverseUp(element, function (el) {
return Util.emptyElementNames.indexOf(el.nodeName.toLowerCase()) === -1;
});
// Selecting at the beginning of a table doesn't work in PhantomJS.
if (element.nodeName.toLowerCase() === 'table') {
var firstCell = element.querySelector('th, td');
if (firstCell) {
element = firstCell;
}
}
return element;
},
// TODO: remove getFirstTextNode AND _getFirstTextNode when jumping in 6.0.0 (no code references)
getFirstTextNode: function (element) {
Util.warn('getFirstTextNode is deprecated and will be removed in version 6.0.0');
return Util._getFirstTextNode(element);
},
_getFirstTextNode: function (element) {
if (element.nodeType === 3) {
return element;
}
for (var i = 0; i < element.childNodes.length; i++) {
var textNode = Util._getFirstTextNode(element.childNodes[i]);
if (textNode !== null) {
return textNode;
}
}
return null;
},
ensureUrlHasProtocol: function (url) {
if (url.indexOf('://') === -1) {
return 'http://' + url;
}
return url;
},
warn: function () {
if (window.console !== undefined && typeof window.console.warn === 'function') {
window.console.warn.apply(window.console, arguments);
}
},
deprecated: function (oldName, newName, version) {
// simple deprecation warning mechanism.
var m = oldName + ' is deprecated, please use ' + newName + ' instead.';
if (version) {
m += ' Will be removed in ' + version;
}
Util.warn(m);
},
deprecatedMethod: function (oldName, newName, args, version) {
// run the replacement and warn when someone calls a deprecated method
Util.deprecated(oldName, newName, version);
if (typeof this[newName] === 'function') {
this[newName].apply(this, args);
}
},
cleanupAttrs: function (el, attrs) {
attrs.forEach(function (attr) {
el.removeAttribute(attr);
});
},
cleanupTags: function (el, tags) {
if (tags.indexOf(el.nodeName.toLowerCase()) !== -1) {
el.parentNode.removeChild(el);
}
},
unwrapTags: function (el, tags) {
if (tags.indexOf(el.nodeName.toLowerCase()) !== -1) {
MediumEditor.util.unwrap(el, document);
}
},
// get the closest parent
getClosestTag: function (el, tag) {
return Util.traverseUp(el, function (element) {
return element.nodeName.toLowerCase() === tag.toLowerCase();
});
},
unwrap: function (el, doc) {
var fragment = doc.createDocumentFragment(),
nodes = Array.prototype.slice.call(el.childNodes);
// cast nodeList to array since appending child
// to a different node will alter length of el.childNodes
for (var i = 0; i < nodes.length; i++) {
fragment.appendChild(nodes[i]);
}
if (fragment.childNodes.length) {
el.parentNode.replaceChild(fragment, el);
} else {
el.parentNode.removeChild(el);
}
},
guid: function () {
function _s4() {
return Math
.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
return _s4() + _s4() + '-' + _s4() + '-' + _s4() + '-' + _s4() + '-' + _s4() + _s4() + _s4();
}
};
MediumEditor.util = Util;
}(window));
(function () {
'use strict';
var Extension = function (options) {
MediumEditor.util.extend(this, options);
};
Extension.extend = function (protoProps) {
// magic extender thinger. mostly borrowed from backbone/goog.inherits
// place this function on some thing you want extend-able.
//
// example:
//
// function Thing(args){
// this.options = args;
// }
//
// Thing.prototype = { foo: "bar" };
// Thing.extend = extenderify;
//
// var ThingTwo = Thing.extend({ foo: "baz" });
//
// var thingOne = new Thing(); // foo === "bar"
// var thingTwo = new ThingTwo(); // foo === "baz"
//
// which seems like some simply shallow copy nonsense
// at first, but a lot more is going on there.
//
// passing a `constructor` to the extend props
// will cause the instance to instantiate through that
// instead of the parent's constructor.
var parent = this,
child;
// The constructor function for the new subclass is either defined by you
// (the "constructor" property in your `extend` definition), or defaulted
// by us to simply call the parent's constructor.
if (protoProps && protoProps.hasOwnProperty('constructor')) {
child = protoProps.constructor;
} else {
child = function () {
return parent.apply(this, arguments);
};
}
// das statics (.extend comes over, so your subclass can have subclasses too)
MediumEditor.util.extend(child, parent);
// Set the prototype chain to inherit from `parent`, without calling
// `parent`'s constructor function.
var Surrogate = function () {
this.constructor = child;
};
Surrogate.prototype = parent.prototype;
child.prototype = new Surrogate();
if (protoProps) {
MediumEditor.util.extend(child.prototype, protoProps);
}
// todo: $super?
return child;
};
Extension.prototype = {
/* init: [function]
*
* Called by MediumEditor during initialization.
* The .base property will already have been set to
* current instance of MediumEditor when this is called.
* All helper methods will exist as well
*/
init: function () {},
/* base: [MediumEditor instance]
*
* If not overriden, this will be set to the current instance
* of MediumEditor, before the init method is called
*/
base: undefined,
/* name: [string]
*
* 'name' of the extension, used for retrieving the extension.
* If not set, MediumEditor will set this to be the key
* used when passing the extension into MediumEditor via the
* 'extensions' option
*/
name: undefined,
/* checkState: [function (node)]
*
* If implemented, this function will be called one or more times
* the state of the editor & toolbar are updated.
* When the state is updated, the editor does the following:
*
* 1) Find the parent node containing the current selection
* 2) Call checkState on the extension, passing the node as an argument
* 3) Get the parent node of the previous node
* 4) Repeat steps #2 and #3 until we move outside the parent contenteditable
*/
checkState: undefined,
/* destroy: [function ()]
*
* This method should remove any created html, custom event handlers
* or any other cleanup tasks that should be performed.
* If implemented, this function will be called when MediumEditor's
* destroy method has been called.
*/
destroy: undefined,
/* As alternatives to checkState, these functions provide a more structured
* path to updating the state of an extension (usually a button) whenever
* the state of the editor & toolbar are updated.
*/
/* queryCommandState: [function ()]
*
* If implemented, this function will be called once on each extension
* when the state of the editor/toolbar is being updated.
*
* If this function returns a non-null value, the extension will
* be ignored as the code climbs the dom tree.
*
* If this function returns true, and the setActive() function is defined
* setActive() will be called
*/
queryCommandState: undefined,
/* isActive: [function ()]
*
* If implemented, this function will be called when MediumEditor
* has determined that this extension is 'active' for the current selection.
* This may be called when the editor & toolbar are being updated,
* but only if queryCommandState() or isAlreadyApplied() functions
* are implemented, and when called, return true.
*/
isActive: undefined,
/* isAlreadyApplied: [function (node)]
*
* If implemented, this function is similar to checkState() in
* that it will be called repeatedly as MediumEditor moves up
* the DOM to update the editor & toolbar after a state change.
*
* NOTE: This function will NOT be called if checkState() has
* been implemented. This function will NOT be called if
* queryCommandState() is implemented and returns a non-null
* value when called
*/
isAlreadyApplied: undefined,
/* setActive: [function ()]
*
* If implemented, this function is called when MediumEditor knows
* that this extension is currently enabled. Currently, this
* function is called when updating the editor & toolbar, and
* only if queryCommandState() or isAlreadyApplied(node) return
* true when called
*/
setActive: undefined,
/* setInactive: [function ()]
*
* If implemented, this function is called when MediumEditor knows
* that this extension is currently disabled. Curently, this
* is called at the beginning of each state change for
* the editor & toolbar. After calling this, MediumEditor
* will attempt to update the extension, either via checkState()
* or the combination of queryCommandState(), isAlreadyApplied(node),
* isActive(), and setActive()
*/
setInactive: undefined,
/* getInteractionElements: [function ()]
*
* If the extension renders any elements that the user can interact with,
* this method should be implemented and return the root element or an array
* containing all of the root elements. MediumEditor will call this function
* during interaction to see if the user clicked on something outside of the editor.
* The elements are used to check if the target element of a click or
* other user event is a descendant of any extension elements.
* This way, the editor can also count user interaction within editor elements as
* interactions with the editor, and thus not trigger 'blur'
*/
getInteractionElements: undefined,
/************************ Helpers ************************
* The following are helpers that are either set by MediumEditor
* during initialization, or are helper methods which either
* route calls to the MediumEditor instance or provide common
* functionality for all extensions
*********************************************************/
/* window: [Window]
*
* If not overriden, this will be set to the window object
* to be used by MediumEditor and its extensions. This is
* passed via the 'contentWindow' option to MediumEditor
* and is the global 'window' object by default
*/
'window': undefined,
/* document: [Document]
*
* If not overriden, this will be set to the document object
* to be used by MediumEditor and its extensions. This is
* passed via the 'ownerDocument' optin to MediumEditor
* and is the global 'document' object by default
*/
'document': undefined,
/* getEditorElements: [function ()]
*
* Helper function which returns an array containing
* all the contenteditable elements for this instance
* of MediumEditor
*/
getEditorElements: function () {
return this.base.elements;
},
/* getEditorId: [function ()]
*
* Helper function which returns a unique identifier
* for this instance of MediumEditor
*/
getEditorId: function () {
return this.base.id;
},
/* getEditorOptions: [function (option)]
*
* Helper function which returns the value of an option
* used to initialize this instance of MediumEditor
*/
getEditorOption: function (option) {
return this.base.options[option];
}
};
/* List of method names to add to the prototype of Extension
* Each of these methods will be defined as helpers that
* just call directly into the MediumEditor instance.
*
* example for 'on' method:
* Extension.prototype.on = function () {
* return this.base.on.apply(this.base, arguments);
* }
*/
[
// general helpers
'execAction',
// event handling
'on',
'off',
'subscribe',
'trigger'
].forEach(function (helper) {
Extension.prototype[helper] = function () {
return this.base[helper].apply(this.base, arguments);
};
});
MediumEditor.Extension = Extension;
})();
(function () {
'use strict';
function filterOnlyParentElements(node) {
if (MediumEditor.util.isBlockContainer(node)) {
return NodeFilter.FILTER_ACCEPT;
} else {
return NodeFilter.FILTER_SKIP;
}
}
var Selection = {
findMatchingSelectionParent: function (testElementFunction, contentWindow) {
var selection = contentWindow.getSelection(),
range,
current;
if (selection.rangeCount === 0) {
return false;
}
range = selection.getRangeAt(0);
current = range.commonAncestorContainer;
return MediumEditor.util.traverseUp(current, testElementFunction);
},
getSelectionElement: function (contentWindow) {
return this.findMatchingSelectionParent(function (el) {
return MediumEditor.util.isMediumEditorElement(el);
}, contentWindow);
},
// http://stackoverflow.com/questions/17678843/cant-restore-selection-after-html-modify-even-if-its-the-same-html
// Tim Down
exportSelection: function (root, doc) {
if (!root) {
return null;
}
var selectionState = null,
selection = doc.getSelection();
if (selection.rangeCount > 0) {
var range = selection.getRangeAt(0),
preSelectionRange = range.cloneRange(),
start;
preSelectionRange.selectNodeContents(root);
preSelectionRange.setEnd(range.startContainer, range.startOffset);
start = preSelectionRange.toString().length;
selectionState = {
start: start,
end: start + range.toString().length
};
// Check to see if the selection starts with any images
// if so we need to make sure the the beginning of the selection is
// set correctly when importing selection
if (this.doesRangeStartWithImages(range, doc)) {
selectionState.startsWithImage = true;
}
// Check to see if the selection has any trailing images
// if so, this this means we need to look for them when we import selection
var trailingImageCount = this.getTrailingImageCount(root, selectionState, range.endContainer, range.endOffset);
if (trailingImageCount) {
selectionState.trailingImageCount = trailingImageCount;
}
// If start = 0 there may still be an empty paragraph before it, but we don't care.
if (start !== 0) {
var emptyBlocksIndex = this.getIndexRelativeToAdjacentEmptyBlocks(doc, root, range.startContainer, range.startOffset);
if (emptyBlocksIndex !== -1) {
selectionState.emptyBlocksIndex = emptyBlocksIndex;
}
}
}
return selectionState;
},
// http://stackoverflow.com/questions/17678843/cant-restore-selection-after-html-modify-even-if-its-the-same-html
// Tim Down
//
// {object} selectionState - the selection to import
// {DOMElement} root - the root element the selection is being restored inside of
// {Document} doc - the document to use for managing selection
// {boolean} [favorLaterSelectionAnchor] - defaults to false. If true, import the cursor immediately
// subsequent to an anchor tag if it would otherwise be placed right at the trailing edge inside the
// anchor. This cursor positioning, even though visually equivalent to the user, can affect behavior
// in MS IE.
importSelection: function (selectionState, root, doc, favorLaterSelectionAnchor) {
if (!selectionState || !root) {
return;
}
var range = doc.createRange();
range.setStart(root, 0);
range.collapse(true);
var node = root,
nodeStack = [],
charIndex = 0,
foundStart = false,
foundEnd = false,
trailingImageCount = 0,
stop = false,
nextCharIndex,
allowRangeToStartAtEndOfNode = false,
lastTextNode = null;
// When importing selection, the start of the selection may lie at the end of an element
// or at the beginning of an element. Since visually there is no difference between these 2
// we will try to move the selection to the beginning of an element since this is generally
// what users will expect and it's a more predictable behavior.
//
// However, there are some specific cases when we don't want to do this:
// 1) We're attempting to move the cursor outside of the end of an anchor [favorLaterSelectionAnchor = true]
// 2) The selection starts with an image, which is special since an image doesn't have any 'content'
// as far as selection and ranges are concerned
// 3) The selection starts after a specified number of empty block elements (selectionState.emptyBlocksIndex)
//
// For these cases, we want the selection to start at a very specific location, so we should NOT
// automatically move the cursor to the beginning of the first actual chunk of text
if (favorLaterSelectionAnchor || selectionState.startsWithImage || typeof selectionState.emptyBlocksIndex !== 'undefined') {
allowRangeToStartAtEndOfNode = true;
}
while (!stop && node) {
// Only iterate over elements and text nodes
if (node.nodeType > 3) {
node = nodeStack.pop();
continue;
}
// If we hit a text node, we need to add the amount of characters to the overall count
if (node.nodeType === 3 && !foundEnd) {
nextCharIndex = charIndex + node.length;
// Check if we're at or beyond the start of the selection we're importing
if (!foundStart && selectionState.start >= charIndex && selectionState.start <= nextCharIndex) {
// NOTE: We only want to allow a selection to start at the END of an element if
// allowRangeToStartAtEndOfNode is true
if (allowRangeToStartAtEndOfNode || selectionState.start < nextCharIndex) {
range.setStart(node, selectionState.start - charIndex);
foundStart = true;
}
// We're at the end of a text node where the selection could start but we shouldn't
// make the selection start here because allowRangeToStartAtEndOfNode is false.
// However, we should keep a reference to this node in case there aren't any more
// text nodes after this, so that we have somewhere to import the selection to
else {
lastTextNode = node;
}
}
// We've found the start of the selection, check if we're at or beyond the end of the selection we're importing
if (foundStart && selectionState.end >= charIndex && selectionState.end <= nextCharIndex) {
if (!selectionState.trailingImageCount) {
range.setEnd(node, selectionState.end - charIndex);
stop = true;
} else {
foundEnd = true;
}
}
charIndex = nextCharIndex;
} else {
if (selectionState.trailingImageCount && foundEnd) {
if (node.nodeName.toLowerCase() === 'img') {
trailingImageCount++;
}
if (trailingImageCount === selectionState.trailingImageCount) {
// Find which index the image is in its parent's children
var endIndex = 0;
while (node.parentNode.childNodes[endIndex] !== node) {
endIndex++;
}
range.setEnd(node.parentNode, endIndex + 1);
stop = true;
}
}
if (!stop && node.nodeType === 1) {
// this is an element
// add all its children to the stack
var i = node.childNodes.length - 1;
while (i >= 0) {
nodeStack.push(node.childNodes[i]);
i -= 1;
}
}
}
if (!stop) {
node = nodeStack.pop();
}
}
// If we've gone through the entire text but didn't find the beginning of a text node
// to make the selection start at, we should fall back to starting the selection
// at the END of the last text node we found
if (!foundStart && lastTextNode) {
range.setStart(lastTextNode, lastTextNode.length);
range.setEnd(lastTextNode, lastTextNode.length);
}
if (typeof selectionState.emptyBlocksIndex !== 'undefined') {
range = this.importSelectionMoveCursorPastBlocks(doc, root, selectionState.emptyBlocksIndex, range);
}
// If the selection is right at the ending edge of a link, put it outside the anchor tag instead of inside.
if (favorLaterSelectionAnchor) {
range = this.importSelectionMoveCursorPastAnchor(selectionState, range);
}
this.selectRange(doc, range);
},
// Utility method called from importSelection only
importSelectionMoveCursorPastAnchor: function (selectionState, range) {
var nodeInsideAnchorTagFunction = function (node) {
return node.nodeName.toLowerCase() === 'a';
};
if (selectionState.start === selectionState.end &&
range.startContainer.nodeType === 3 &&
range.startOffset === range.startContainer.nodeValue.length &&
MediumEditor.util.traverseUp(range.startContainer, nodeInsideAnchorTagFunction)) {
var prevNode = range.startContainer,
currentNode = range.startContainer.parentNode;
while (currentNode !== null && currentNode.nodeName.toLowerCase() !== 'a') {
if (currentNode.childNodes[currentNode.childNodes.length - 1] !== prevNode) {
currentNode = null;
} else {
prevNode = currentNode;
currentNode = currentNode.parentNode;
}
}
if (currentNode !== null && currentNode.nodeName.toLowerCase() === 'a') {
var currentNodeIndex = null;
for (var i = 0; currentNodeIndex === null && i < currentNode.parentNode.childNodes.length; i++) {
if (currentNode.parentNode.childNodes[i] === currentNode) {
currentNodeIndex = i;
}
}
range.setStart(currentNode.parentNode, currentNodeIndex + 1);
range.collapse(true);
}
}
return range;
},
// Uses the emptyBlocksIndex calculated by getIndexRelativeToAdjacentEmptyBlocks
// to move the cursor back to the start of the correct paragraph
importSelectionMoveCursorPastBlocks: function (doc, root, index, range) {
var treeWalker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, filterOnlyParentElements, false),
startContainer = range.startContainer,
startBlock,
targetNode,
currIndex = 0;
index = index || 1; // If index is 0, we still want to move to the next block
// Chrome counts newlines and spaces that separate block elements as actual elements.
// If the selection is inside one of these text nodes, and it has a previous sibling
// which is a block element, we want the treewalker to start at the previous sibling
// and NOT at the parent of the textnode
if (startContainer.nodeType === 3 && MediumEditor.util.isBlockContainer(startContainer.previousSibling)) {
startBlock = startContainer.previousSibling;
} else {
startBlock = MediumEditor.util.getClosestBlockContainer(startContainer);
}
// Skip over empty blocks until we hit the block we want the selection to be in
while (treeWalker.nextNode()) {
if (!targetNode) {
// Loop through all blocks until we hit the starting block element
if (startBlock === treeWalker.currentNode) {
targetNode = treeWalker.currentNode;
}
} else {
targetNode = treeWalker.currentNode;
currIndex++;
// We hit the target index, bail
if (currIndex === index) {
break;
}
// If we find a non-empty block, ignore the emptyBlocksIndex and just put selection here
if (targetNode.textContent.length > 0) {
break;
}
}
}
if (!targetNode) {
targetNode = startBlock;
}
// We're selecting a high-level block node, so make sure the cursor gets moved into the deepest
// element at the beginning of the block
range.setStart(MediumEditor.util.getFirstSelectableLeafNode(targetNode), 0);
return range;
},
// Returns -1 unless the cursor is at the beginning of a paragraph/block
// If the paragraph/block is preceeded by empty paragraphs/block (with no text)
// it will return the number of empty paragraphs before the cursor.
// Otherwise, it will return 0, which indicates the cursor is at the beginning
// of a paragraph/block, and not at the end of the paragraph/block before it
getIndexRelativeToAdjacentEmptyBlocks: function (doc, root, cursorContainer, cursorOffset) {
// If there is text in front of the cursor, that means there isn't only empty blocks before it
if (cursorContainer.textContent.length > 0 && cursorOffset > 0) {
return -1;
}
// Check if the block that contains the cursor has any other text in front of the cursor
var node = cursorContainer;
if (node.nodeType !== 3) {
node = cursorContainer.childNodes[cursorOffset];
}
if (node) {
// The element isn't at the beginning of a block, so it has content before it
if (!MediumEditor.util.isElementAtBeginningOfBlock(node)) {
return -1;
}
var previousSibling = MediumEditor.util.findPreviousSibling(node);
// If there is no previous sibling, this is the first text element in the editor
if (!previousSibling) {
return -1;
}
// If the previous sibling has text, then there are no empty blocks before this
else if (previousSibling.nodeValue) {
return -1;
}
}
// Walk over block elements, counting number of empty blocks between last piece of text
// and the block the cursor is in
var closestBlock = MediumEditor.util.getClosestBlockContainer(cursorContainer),
treeWalker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, filterOnlyParentElements, false),
emptyBlocksCount = 0;
while (treeWalker.nextNode()) {
var blockIsEmpty = treeWalker.currentNode.textContent === '';
if (blockIsEmpty || emptyBlocksCount > 0) {
emptyBlocksCount += 1;
}
if (treeWalker.currentNode === closestBlock) {
return emptyBlocksCount;
}
if (!blockIsEmpty) {
emptyBlocksCount = 0;
}
}
return emptyBlocksCount;
},
// Returns true if the selection range begins with an image tag
// Returns false if the range starts with any non empty text nodes
doesRangeStartWithImages: function (range, doc) {
if (range.startOffset !== 0 || range.startContainer.nodeType !== 1) {
return false;
}
if (range.startContainer.nodeName.toLowerCase() === 'img') {
return true;
}
var img = range.startContainer.querySelector('img');
if (!img) {
return false;
}
var treeWalker = doc.createTreeWalker(range.startContainer, NodeFilter.SHOW_ALL, null, false);
while (treeWalker.nextNode()) {
var next = treeWalker.currentNode;
// If we hit the image, then there isn't any text before the image so
// the image is at the beginning of the range
if (next === img) {
break;
}
// If we haven't hit the iamge, but found text that contains content
// then the range doesn't start with an image
if (next.nodeValue) {
return false;
}
}
return true;
},
getTrailingImageCount: function (root, selectionState, endContainer, endOffset) {
// If the endOffset of a range is 0, the endContainer doesn't contain images
// If the endContainer is a text node, there are no trailing images
if (endOffset === 0 || endContainer.nodeType !== 1) {
return 0;
}
// If the endContainer isn't an image, and doesn't have an image descendants
// there are no trailing images
if (endContainer.nodeName.toLowerCase() !== 'img' && !endContainer.querySelector('img')) {
return 0;
}
var lastNode = endContainer.childNodes[endOffset - 1];
while (lastNode.hasChildNodes()) {
lastNode = lastNode.lastChild;
}
var node = root,
nodeStack = [],
charIndex = 0,
foundStart = false,
foundEnd = false,
stop = false,
nextCharIndex,
trailingImages = 0;
while (!stop && node) {
// Only iterate over elements and text nodes
if (node.nodeType > 3) {
node = nodeStack.pop();
continue;
}
if (node.nodeType === 3 && !foundEnd) {
trailingImages = 0;
nextCharIndex = charIndex + node.length;
if (!foundStart && selectionState.start >= charIndex && selectionState.start <= nextCharIndex) {
foundStart = true;
}
if (foundStart && selectionState.end >= charIndex && selectionState.end <= nextCharIndex) {
foundEnd = true;
}
charIndex = nextCharIndex;
} else {
if (node.nodeName.toLowerCase() === 'img') {
trailingImages++;
}
if (node === lastNode) {
stop = true;
} else if (node.nodeType === 1) {
// this is an element
// add all its children to the stack
var i = node.childNodes.length - 1;
while (i >= 0) {
nodeStack.push(node.childNodes[i]);
i -= 1;
}
}
}
if (!stop) {
node = nodeStack.pop();
}
}
return trailingImages;
},
// determine if the current selection contains any 'content'
// content being any non-white space text or an image
selectionContainsContent: function (doc) {
var sel = doc.getSelection();
// collapsed selection or selection withour range doesn't contain content
if (!sel || sel.isCollapsed || !sel.rangeCount) {
return false;
}
// if toString() contains any text, the selection contains some content
if (sel.toString().trim() !== '') {
return true;
}
// if selection contains only image(s), it will return empty for toString()
// so check for an image manually
var selectionNode = this.getSelectedParentElement(sel.getRangeAt(0));
if (selectionNode) {
if (selectionNode.nodeName.toLowerCase() === 'img' ||
(selectionNode.nodeType === 1 && selectionNode.querySelector('img'))) {
return true;
}
}
return false;
},
selectionInContentEditableFalse: function (contentWindow) {
// determine if the current selection is exclusively inside
// a contenteditable="false", though treat the case of an
// explicit contenteditable="true" inside a "false" as false.
var sawtrue,
sawfalse = this.findMatchingSelectionParent(function (el) {
var ce = el && el.getAttribute('contenteditable');
if (ce === 'true') {
sawtrue = true;
}
return el.nodeName !== '#text' && ce === 'false';
}, contentWindow);
return !sawtrue && sawfalse;
},
// http://stackoverflow.com/questions/4176923/html-of-selected-text
// by Tim Down
getSelectionHtml: function getSelectionHtml(doc) {
var i,
html = '',
sel = doc.getSelection(),
len,
container;
if (sel.rangeCount) {
container = doc.createElement('div');
for (i = 0, len = sel.rangeCount; i < len; i += 1) {
container.appendChild(sel.getRangeAt(i).cloneContents());
}
html = container.innerHTML;
}
return html;
},
/**
* Find the caret position within an element irrespective of any inline tags it may contain.
*
* @param {DOMElement} An element containing the cursor to find offsets relative to.
* @param {Range} A Range representing cursor position. Will window.getSelection if none is passed.
* @return {Object} 'left' and 'right' attributes contain offsets from begining and end of Element
*/
getCaretOffsets: function getCaretOffsets(element, range) {
var preCaretRange, postCaretRange;
if (!range) {
range = window.getSelection().getRangeAt(0);
}
preCaretRange = range.cloneRange();
postCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(range.endContainer, range.endOffset);
postCaretRange.selectNodeContents(element);
postCaretRange.setStart(range.endContainer, range.endOffset);
return {
left: preCaretRange.toString().length,
right: postCaretRange.toString().length
};
},
// http://stackoverflow.com/questions/15867542/range-object-get-selection-parent-node-chrome-vs-firefox
rangeSelectsSingleNode: function (range) {
var startNode = range.startContainer;
return startNode === range.endContainer &&
startNode.hasChildNodes() &&
range.endOffset === range.startOffset + 1;
},
getSelectedParentElement: function (range) {
if (!range) {
return null;
}
// Selection encompasses a single element
if (this.rangeSelectsSingleNode(range) && range.startContainer.childNodes[range.startOffset].nodeType !== 3) {
return range.startContainer.childNodes[range.startOffset];
}
// Selection range starts inside a text node, so get its parent
if (range.startContainer.nodeType === 3) {
return range.startContainer.parentNode;
}
// Selection starts inside an element
return range.startContainer;
},
getSelectedElements: function (doc) {
var selection = doc.getSelection(),
range,
toRet,
currNode;
if (!selection.rangeCount || selection.isCollapsed || !selection.getRangeAt(0).commonAncestorContainer) {
return [];
}
range = selection.getRangeAt(0);
if (range.commonAncestorContainer.nodeType === 3) {
toRet = [];
currNode = range.commonAncestorContainer;
while (currNode.parentNode && currNode.parentNode.childNodes.length === 1) {
toRet.push(currNode.parentNode);
currNode = currNode.parentNode;
}
return toRet;
}
return [].filter.call(range.commonAncestorContainer.getElementsByTagName('*'), function (el) {
return (typeof selection.containsNode === 'function') ? selection.containsNode(el, true) : true;
});
},
selectNode: function (node, doc) {
var range = doc.createRange();
range.selectNodeContents(node);
this.selectRange(doc, range);
},
select: function (doc, startNode, startOffset, endNode, endOffset) {
var range = doc.createRange();
range.setStart(startNode, startOffset);
if (endNode) {
range.setEnd(endNode, endOffset);
} else {
range.collapse(true);
}
this.selectRange(doc, range);
return range;
},
/**
* Clear the current highlighted selection and set the caret to the start or the end of that prior selection, defaults to end.
*
* @param {DomDocument} doc Current document
* @param {boolean} moveCursorToStart A boolean representing whether or not to set the caret to the beginning of the prior selection.
*/
clearSelection: function (doc, moveCursorToStart) {
if (moveCursorToStart) {
doc.getSelection().collapseToStart();
} else {
doc.getSelection().collapseToEnd();
}
},
/**
* Move cursor to the given node with the given offset.
*
* @param {DomDocument} doc Current document
* @param {DomElement} node Element where to jump
* @param {integer} offset Where in the element should we jump, 0 by default
*/
moveCursor: function (doc, node, offset) {
this.select(doc, node, offset);
},
getSelectionRange: function (ownerDocument) {
var selection = ownerDocument.getSelection();
if (selection.rangeCount === 0) {
return null;
}
return selection.getRangeAt(0);
},
selectRange: function (ownerDocument, range) {
var selection = ownerDocument.getSelection();
selection.removeAllRanges();
selection.addRange(range);
},
// http://stackoverflow.com/questions/1197401/how-can-i-get-the-element-the-caret-is-in-with-javascript-when-using-contentedi
// by You
getSelectionStart: function (ownerDocument) {
var node = ownerDocument.getSelection().anchorNode,
startNode = (node && node.nodeType === 3 ? node.parentNode : node);
return startNode;
}
};
MediumEditor.selection = Selection;
}());
(function () {
'use strict';
function isElementDescendantOfExtension(extensions, element) {
if (!extensions) {
return false;
}
return extensions.some(function (extension) {
if (typeof extension.getInteractionElements !== 'function') {
return false;
}
var extensionElements = extension.getInteractionElements();
if (!extensionElements) {
return false;
}
if (!Array.isArray(extensionElements)) {
extensionElements = [extensionElements];
}
return extensionElements.some(function (el) {
return MediumEditor.util.isDescendant(el, element, true);
});
});
}
var Events = function (instance) {
this.base = instance;
this.options = this.base.options;
this.events = [];
this.disabledEvents = {};
this.customEvents = {};
this.listeners = {};
};
Events.prototype = {
InputEventOnContenteditableSupported: !MediumEditor.util.isIE && !MediumEditor.util.isEdge,
// Helpers for event handling
attachDOMEvent: function (targets, event, listener, useCapture) {
var win = this.base.options.contentWindow,
doc = this.base.options.ownerDocument;
targets = MediumEditor.util.isElement(targets) || [win, doc].indexOf(targets) > -1 ? [targets] : targets;
Array.prototype.forEach.call(targets, function (target) {
target.addEventListener(event, listener, useCapture);
this.events.push([target, event, listener, useCapture]);
}.bind(this));
},
detachDOMEvent: function (targets, event, listener, useCapture) {
var index, e,
win = this.base.options.contentWindow,
doc = this.base.options.ownerDocument;
if (targets) {
targets = MediumEditor.util.isElement(targets) || [win, doc].indexOf(targets) > -1 ? [targets] : targets;
Array.prototype.forEach.call(targets, function (target) {
index = this.indexOfListener(target, event, listener, useCapture);
if (index !== -1) {
e = this.events.splice(index, 1)[0];
e[0].removeEventListener(e[1], e[2], e[3]);
}
}.bind(this));
}
},
indexOfListener: function (target, event, listener, useCapture) {
var i, n, item;
for (i = 0, n = this.events.length; i < n; i = i + 1) {
item = this.events[i];
if (item[0] === target && item[1] === event && item[2] === listener && item[3] === useCapture) {
return i;
}
}
return -1;
},
detachAllDOMEvents: function () {
var e = this.events.pop();
while (e) {
e[0].removeEventListener(e[1], e[2], e[3]);
e = this.events.pop();
}
},
detachAllEventsFromElement: function (element) {
var filtered = this.events.filter(function (e) {
return e && e[0].getAttribute && e[0].getAttribute('medium-editor-index') === element.getAttribute('medium-editor-index');
});
for (var i = 0, len = filtered.length; i < len; i++) {
var e = filtered[i];
this.detachDOMEvent(e[0], e[1], e[2], e[3]);
}
},
// Attach all existing handlers to a new element
attachAllEventsToElement: function (element) {
if (this.listeners['editableInput']) {
this.contentCache[element.getAttribute('medium-editor-index')] = element.innerHTML;
}
if (this.eventsCache) {
this.eventsCache.forEach(function (e) {
this.attachDOMEvent(element, e['name'], e['handler'].bind(this));
}, this);
}
},
enableCustomEvent: function (event) {
if (this.disabledEvents[event] !== undefined) {
delete this.disabledEvents[event];
}
},
disableCustomEvent: function (event) {
this.disabledEvents[event] = true;
},
// custom events
attachCustomEvent: function (event, listener) {
this.setupListener(event);
if (!this.customEvents[event]) {
this.customEvents[event] = [];
}
this.customEvents[event].push(listener);
},
detachCustomEvent: function (event, listener) {
var index = this.indexOfCustomListener(event, listener);
if (index !== -1) {
this.customEvents[event].splice(index, 1);
// TODO: If array is empty, should detach internal listeners via destroyListener()
}
},
indexOfCustomListener: function (event, listener) {
if (!this.customEvents[event] || !this.customEvents[event].length) {
return -1;
}
return this.customEvents[event].indexOf(listener);
},
detachAllCustomEvents: function () {
this.customEvents = {};
// TODO: Should detach internal listeners here via destroyListener()
},
triggerCustomEvent: function (name, data, editable) {
if (this.customEvents[name] && !this.disabledEvents[name]) {
this.customEvents[name].forEach(function (listener) {
listener(data, editable);
});
}
},
// Cleaning up
destroy: function () {
this.detachAllDOMEvents();
this.detachAllCustomEvents();
this.detachExecCommand();
if (this.base.elements) {
this.base.elements.forEach(function (element) {
element.removeAttribute('data-medium-focused');
});
}
},
// Listening to calls to document.execCommand
// Attach a listener to be notified when document.execCommand is called
attachToExecCommand: function () {
if (this.execCommandListener) {
return;
}
// Store an instance of the listener so:
// 1) We only attach to execCommand once
// 2) We can remove the listener later
this.execCommandListener = function (execInfo) {
this.handleDocumentExecCommand(execInfo);
}.bind(this);
// Ensure that execCommand has been wrapped correctly
this.wrapExecCommand();
// Add listener to list of execCommand listeners
this.options.ownerDocument.execCommand.listeners.push(this.execCommandListener);
},
// Remove our listener for calls to document.execCommand
detachExecCommand: function () {
var doc = this.options.ownerDocument;
if (!this.execCommandListener || !doc.execCommand.listeners) {
return;
}
// Find the index of this listener in the array of listeners so it can be removed
var index = doc.execCommand.listeners.indexOf(this.execCommandListener);
if (index !== -1) {
doc.execCommand.listeners.splice(index, 1);
}
// If the list of listeners is now empty, put execCommand back to its original state
if (!doc.execCommand.listeners.length) {
this.unwrapExecCommand();
}
},
// Wrap document.execCommand in a custom method so we can listen to calls to it
wrapExecCommand: function () {
var doc = this.options.ownerDocument;
// Ensure all instance of MediumEditor only wrap execCommand once
if (doc.execCommand.listeners) {
return;
}
// Helper method to call all listeners to execCommand
var callListeners = function (args, result) {
if (doc.execCommand.listeners) {
doc.execCommand.listeners.forEach(function (listener) {
listener({
command: args[0],
value: args[2],
args: args,
result: result
});
});
}
},
// 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
wrapper = function () {
var result = doc.execCommand.orig.apply(this, arguments);
if (!doc.execCommand.listeners) {
return result;
}
var args = Array.prototype.slice.call(arguments);
callListeners(args, result);
return result;
};
// Store a reference to the original execCommand
wrapper.orig = doc.execCommand;
// Attach an array for storing listeners
wrapper.listeners = [];
// Helper for notifying listeners
wrapper.callListeners = callListeners;
// 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;
}, this);
// Attach to the 'oninput' event, handled correctly by most browsers
if (this.InputEventOnContenteditableSupported) {
this.attachToEachElement('input', this.handleInput);
}
// For browsers which don't support the input event on contenteditable (IE)
// we'll attach to 'selectionchange' on the document and 'keypress' on the editables
if (!this.InputEventOnContenteditableSupported) {
this.setupListener('editableKeypress');
this.keypressUpdateInput = true;
this.attachDOMEvent(document, 'selectionchange', this.handleDocumentSelectionChange.bind(this));
// Listen to calls to execCommand
this.attachToExecCommand();
}
break;
case 'editableClick':
// Detecting click in the contenteditables
this.attachToEachElement('click', this.handleClick);
break;
case 'editableBlur':
// Detecting blur in the contenteditables
this.attachToEachElement('blur', this.handleBlur);
break;
case 'editableKeypress':
// Detecting keypress in the contenteditables
this.attachToEachElement('keypress', this.handleKeypress);
break;
case 'editableKeyup':
// Detecting keyup in the contenteditables
this.attachToEachElement('keyup', this.handleKeyup);
break;
case 'editableKeydown':
// Detecting keydown on the contenteditables
this.attachToEachElement('keydown', this.handleKeydown);
break;
case 'editableKeydownSpace':
// Detecting keydown for SPACE on the contenteditables
this.setupListener('editableKeydown');
break;
case 'editableKeydownEnter':
// Detecting keydown for ENTER on the contenteditables
this.setupListener('editableKeydown');
break;
case 'editableKeydownTab':
// Detecting keydown for TAB on the contenteditable
this.setupListener('editableKeydown');
break;
case 'editableKeydownDelete':
// Detecting keydown for DELETE/BACKSPACE on the contenteditables
this.setupListener('editableKeydown');
break;
case 'editableMouseover':
// Detecting mouseover on the contenteditables
this.attachToEachElement('mouseover', this.handleMouseover);
break;
case 'editableDrag':
// Detecting dragover and dragleave on the contenteditables
this.attachToEachElement('dragover', this.handleDragging);
this.attachToEachElement('dragleave', this.handleDragging);
break;
case 'editableDrop':
// Detecting drop on the contenteditables
this.attachToEachElement('drop', this.handleDrop);
break;
// TODO: We need to have a custom 'paste' event separate from 'editablePaste'
// Need to think about the way to introduce this without breaking folks
case 'editablePaste':
// Detecting paste on the contenteditables
this.attachToEachElement('paste', this.handlePaste);
break;
}
this.listeners[name] = true;
},
attachToEachElement: function (name, handler) {
// build our internal cache to know which element got already what handler attached
if (!this.eventsCache) {
this.eventsCache = [];
}
this.base.elements.forEach(function (element) {
this.attachDOMEvent(element, name, handler.bind(this));
}, this);
this.eventsCache.push({ 'name': name, 'handler': handler });
},
cleanupElement: function (element) {
var index = element.getAttribute('medium-editor-index');
if (index) {
this.detachAllEventsFromElement(element);
if (this.contentCache) {
delete this.contentCache[index];
}
}
},
focusElement: function (element) {
element.focus();
this.updateFocus(element, { target: element, type: 'focus' });
},
updateFocus: function (target, eventObj) {
var hadFocus = this.base.getFocusedElement(),
toFocus;
// For clicks, we need to know if the mousedown that caused the click happened inside the existing focused element
// or one of the extension elements. If so, we don't want to focus another element
if (hadFocus &&
eventObj.type === 'click' &&
this.lastMousedownTarget &&
(MediumEditor.util.isDescendant(hadFocus, this.lastMousedownTarget, true) ||
isElementDescendantOfExtension(this.base.extensions, this.lastMousedownTarget))) {
toFocus = hadFocus;
}
if (!toFocus) {
this.base.elements.some(function (element) {
// If the target is part of an editor element, this is the element getting focus
if (!toFocus && (MediumEditor.util.isDescendant(element, target, true))) {
toFocus = element;
}
// bail if we found an element that's getting focus
return !!toFocus;
}, this);
}
// Check if the target is external (not part of the editor, toolbar, or any other extension)
var externalEvent = !MediumEditor.util.isDescendant(hadFocus, target, true) &&
!isElementDescendantOfExtension(this.base.extensions, target);
if (toFocus !== hadFocus) {
// If element has focus, and focus is going outside of editor
// Don't blur focused element if clicking on editor, toolbar, or anchorpreview
if (hadFocus && externalEvent) {
// Trigger blur on the editable that has lost focus
hadFocus.removeAttribute('data-medium-focused');
this.triggerCustomEvent('blur', eventObj, hadFocus);
}
// If focus is going into an editor element
if (toFocus) {
// Trigger focus on the editable that now has focus
toFocus.setAttribute('data-medium-focused', true);
this.triggerCustomEvent('focus', eventObj, toFocus);
}
}
if (externalEvent) {
this.triggerCustomEvent('externalInteraction', eventObj);
}
},
updateInput: function (target, eventObj) {
if (!this.contentCache) {
return;
}
// An event triggered which signifies that the user may have changed someting
// Look in our cache of input for the contenteditables to see if something changed
var index = target.getAttribute('medium-editor-index'),
html = target.innerHTML;
if (html !== this.contentCache[index]) {
// The content has changed since the last time we checked, fire the event
this.triggerCustomEvent('editableInput', eventObj, target);
}
this.contentCache[index] = html;
},
handleDocumentSelectionChange: function (event) {
// When selectionchange fires, target and current target are set
// to document, since this is where the event is handled
// However, currentTarget will have an 'activeElement' property
// which will point to whatever element has focus.
if (event.currentTarget && event.currentTarget.activeElement) {
var activeElement = event.currentTarget.activeElement,
currentTarget;
// We can look at the 'activeElement' to determine if the selectionchange has
// happened within a contenteditable owned by this instance of MediumEditor
this.base.elements.some(function (element) {
if (MediumEditor.util.isDescendant(element, activeElement, true)) {
currentTarget = element;
return true;
}
return false;
}, this);
// We know selectionchange fired within one of our contenteditables
if (currentTarget) {
this.updateInput(currentTarget, { target: activeElement, currentTarget: currentTarget });
}
}
},
handleDocumentExecCommand: function () {
// document.execCommand has been called
// If one of our contenteditables currently has focus, we should
// attempt to trigger the 'editableInput' event
var target = this.base.getFocusedElement();
if (target) {
this.updateInput(target, { target: target, currentTarget: target });
}
},
handleBodyClick: function (event) {
this.updateFocus(event.target, event);
},
handleBodyFocus: function (event) {
this.updateFocus(event.target, event);
},
handleBodyMousedown: function (event) {
this.lastMousedownTarget = event.target;
},
handleInput: function (event) {
this.updateInput(event.currentTarget, event);
},
handleClick: function (event) {
this.triggerCustomEvent('editableClick', event, event.currentTarget);
},
handleBlur: function (event) {
this.triggerCustomEvent('editableBlur', event, event.currentTarget);
},
handleKeypress: function (event) {
this.triggerCustomEvent('editableKeypress', event, event.currentTarget);
// If we're doing manual detection of the editableInput event we need
// to check for input changes during 'keypress'
if (this.keypressUpdateInput) {
var eventObj = { target: event.target, currentTarget: event.currentTarget };
// In IE, we need to let the rest of the event stack complete before we detect
// changes to input, so using setTimeout here
setTimeout(function () {
this.updateInput(eventObj.currentTarget, eventObj);
}.bind(this), 0);
}
},
handleKeyup: function (event) {
this.triggerCustomEvent('editableKeyup', event, event.currentTarget);
},
handleMouseover: function (event) {
this.triggerCustomEvent('editableMouseover', event, event.currentTarget);
},
handleDragging: function (event) {
this.triggerCustomEvent('editableDrag', event, event.currentTarget);
},
handleDrop: function (event) {
this.triggerCustomEvent('editableDrop', event, event.currentTarget);
},
handlePaste: function (event) {
this.triggerCustomEvent('editablePaste', event, event.currentTarget);
},
handleKeydown: function (event) {
this.triggerCustomEvent('editableKeydown', event, event.currentTarget);
if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.SPACE)) {
return this.triggerCustomEvent('editableKeydownSpace', event, event.currentTarget);
}
if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER) || (event.ctrlKey && MediumEditor.util.isKey(event, MediumEditor.util.keyCode.M))) {
return this.triggerCustomEvent('editableKeydownEnter', event, event.currentTarget);
}
if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.TAB)) {
return this.triggerCustomEvent('editableKeydownTab', event, event.currentTarget);
}
if (MediumEditor.util.isKey(event, [MediumEditor.util.keyCode.DELETE, MediumEditor.util.keyCode.BACKSPACE])) {
return this.triggerCustomEvent('editableKeydownDelete', event, event.currentTarget);
}
}
};
MediumEditor.Events = Events;
}());
(function () {
'use strict';
var Button = MediumEditor.Extension.extend({
/* Button Options */
/* action: [string]
* The action argument to pass to MediumEditor.execAction()
* when the button is clicked
*/
action: undefined,
/* aria: [string]
* The value to add as the aria-label attribute of the button
* element displayed in the toolbar.
* This is also used as the tooltip for the button
*/
aria: undefined,
/* tagNames: [Array]
* NOTE: This is not used if useQueryState is set to true.
*
* Array of element tag names that would indicate that this
* button has already been applied. If this action has already
* been applied, the button will be displayed as 'active' in the toolbar
*
* Example:
* For 'bold', if the text is ever within a <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,
// The button constructor can optionally accept the name of a built-in button
// (ie 'bold', 'italic', etc.)
// When the name of a button is passed, it will initialize itself with the
// configuration for that button
constructor: function (options) {
if (Button.isBuiltInButton(options)) {
MediumEditor.Extension.call(this, this.defaults[options]);
} else {
MediumEditor.Extension.call(this, options);
}
},
init: function () {
MediumEditor.Extension.prototype.init.apply(this, arguments);
this.button = this.createButton();
this.on(this.button, 'click', this.handleClick.bind(this));
},
/* getButton: [function ()]
*
* If implemented, this function will be called when
* the toolbar is being created. The DOM Element returned
* by this function will be appended to the toolbar along
* with any other buttons.
*/
getButton: function () {
return this.button;
},
getAction: function () {
return (typeof this.action === 'function') ? this.action(this.base.options) : this.action;
},
getAria: function () {
return (typeof this.aria === 'function') ? this.aria(this.base.options) : this.aria;
},
getTagNames: function () {
return (typeof this.tagNames === 'function') ? this.tagNames(this.base.options) : this.tagNames;
},
createButton: function () {
var button = this.document.createElement('button'),
content = this.contentDefault,
ariaLabel = this.getAria(),
buttonLabels = this.getEditorOption('buttonLabels');
// Add class names
button.classList.add('medium-editor-action');
button.classList.add('medium-editor-action-' + this.name);
if (this.classList) {
this.classList.forEach(function (className) {
button.classList.add(className);
});
}
// Add attributes
button.setAttribute('data-action', this.getAction());
if (ariaLabel) {
button.setAttribute('title', ariaLabel);
button.setAttribute('aria-label', ariaLabel);
}
if (this.attrs) {
Object.keys(this.attrs).forEach(function (attr) {
button.setAttribute(attr, this.attrs[attr]);
}, this);
}
if (buttonLabels === 'fontawesome' && this.contentFA) {
content = this.contentFA;
}
button.innerHTML = content;
return button;
},
handleClick: function (event) {
event.preventDefault();
event.stopPropagation();
var action = this.getAction();
if (action) {
this.execAction(action);
}
},
isActive: function () {
return this.button.classList.contains(this.getEditorOption('activeButtonClass'));
},
setInactive: function () {
this.button.classList.remove(this.getEditorOption('activeButtonClass'));
delete this.knownState;
},
setActive: function () {
this.button.classList.add(this.getEditorOption('activeButtonClass'));
delete this.knownState;
},
queryCommandState: function () {
var queryState = null;
if (this.useQueryState) {
queryState = this.base.queryCommandState(this.getAction());
}
return queryState;
},
isAlreadyApplied: function (node) {
var isMatch = false,
tagNames = this.getTagNames(),
styleVals,
computedStyle;
if (this.knownState === false || this.knownState === true) {
return this.knownState;
}
if (tagNames && tagNames.length > 0) {
isMatch = tagNames.indexOf(node.nodeName.toLowerCase()) !== -1;
}
if (!isMatch && this.style) {
styleVals = this.style.value.split('|');
computedStyle = this.window.getComputedStyle(node, null).getPropertyValue(this.style.prop);
styleVals.forEach(function (val) {
if (!this.knownState) {
isMatch = (computedStyle.indexOf(val) !== -1);
// text-decoration is not inherited by default
// so if the computed style for text-decoration doesn't match
// don't write to knownState so we can fallback to other checks
if (isMatch || this.style.prop !== 'text-decoration') {
this.knownState = isMatch;
}
}
}, this);
}
return isMatch;
}
});
Button.isBuiltInButton = function (name) {
return (typeof name === 'string') && MediumEditor.extensions.button.prototype.defaults.hasOwnProperty(name);
};
MediumEditor.extensions.button = Button;
}());
(function () {
'use strict';
/* MediumEditor.extensions.button.defaults: [Object]
* Set of default config options for all of the built-in MediumEditor buttons
*/
MediumEditor.extensions.button.prototype.defaults = {
'bold': {
name: 'bold',
action: 'bold',
aria: 'bold',
tagNames: ['b', 'strong'],
style: {
prop: 'font-weight',
value: '700|bold'
},
useQueryState: true,
contentDefault: '<b>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>'
},
'html': {
name: 'html',
action: 'html',
aria: 'evaluate html',
tagNames: ['iframe', 'object'],
contentDefault: '<b>html</b>',
contentFA: '<i class="fa fa-code"></i>'
},
'orderedlist': {
name: 'orderedlist',
action: 'insertorderedlist',
aria: 'ordered list',
tagNames: ['ol'],
useQueryState: true,
contentDefault: '<b>1.</b>',
contentFA: '<i class="fa fa-list-ol"></i>'
},
'unorderedlist': {
name: 'unorderedlist',
action: 'insertunorderedlist',
aria: 'unordered list',
tagNames: ['ul'],
useQueryState: true,
contentDefault: '<b>&bull;</b>',
contentFA: '<i class="fa fa-list-ul"></i>'
},
'indent': {
name: 'indent',
action: 'indent',
aria: 'indent',
tagNames: [],
contentDefault: '<b>&rarr;</b>',
contentFA: '<i class="fa fa-indent"></i>'
},
'outdent': {
name: 'outdent',
action: 'outdent',
aria: 'outdent',
tagNames: [],
contentDefault: '<b>&larr;</b>',
contentFA: '<i class="fa fa-outdent"></i>'
},
'justifyCenter': {
name: 'justifyCenter',
action: 'justifyCenter',
aria: 'center justify',
tagNames: [],
style: {
prop: 'text-align',
value: 'center'
},
contentDefault: '<b>C</b>',
contentFA: '<i class="fa fa-align-center"></i>'
},
'justifyFull': {
name: 'justifyFull',
action: 'justifyFull',
aria: 'full justify',
tagNames: [],
style: {
prop: 'text-align',
value: 'justify'
},
contentDefault: '<b>J</b>',
contentFA: '<i class="fa fa-align-justify"></i>'
},
'justifyLeft': {
name: 'justifyLeft',
action: 'justifyLeft',
aria: 'left justify',
tagNames: [],
style: {
prop: 'text-align',
value: 'left'
},
contentDefault: '<b>L</b>',
contentFA: '<i class="fa fa-align-left"></i>'
},
'justifyRight': {
name: 'justifyRight',
action: 'justifyRight',
aria: 'right justify',
tagNames: [],
style: {
prop: 'text-align',
value: 'right'
},
contentDefault: '<b>R</b>',
contentFA: '<i class="fa fa-align-right"></i>'
},
// Known inline elements that are not removed, or not removed consistantly across browsers:
// <span>, <label>, <br>
'removeFormat': {
name: 'removeFormat',
aria: 'remove formatting',
action: 'removeFormat',
contentDefault: '<b>X</b>',
contentFA: '<i class="fa fa-eraser"></i>'
},
/***** Buttons for appending block elements (append-<element> action) *****/
'quote': {
name: 'quote',
action: 'append-blockquote',
aria: 'blockquote',
tagNames: ['blockquote'],
contentDefault: '<b>&ldquo;</b>',
contentFA: '<i class="fa fa-quote-right"></i>'
},
'pre': {
name: 'pre',
action: 'append-pre',
aria: 'preformatted text',
tagNames: ['pre'],
contentDefault: '<b>0101</b>',
contentFA: '<i class="fa fa-code fa-lg"></i>'
},
'h1': {
name: 'h1',
action: 'append-h1',
aria: 'header type one',
tagNames: ['h1'],
contentDefault: '<b>H1</b>',
contentFA: '<i class="fa fa-header"><sup>1</sup>'
},
'h2': {
name: 'h2',
action: 'append-h2',
aria: 'header type two',
tagNames: ['h2'],
contentDefault: '<b>H2</b>',
contentFA: '<i class="fa fa-header"><sup>2</sup>'
},
'h3': {
name: 'h3',
action: 'append-h3',
aria: 'header type three',
tagNames: ['h3'],
contentDefault: '<b>H3</b>',
contentFA: '<i class="fa fa-header"><sup>3</sup>'
},
'h4': {
name: 'h4',
action: 'append-h4',
aria: 'header type four',
tagNames: ['h4'],
contentDefault: '<b>H4</b>',
contentFA: '<i class="fa fa-header"><sup>4</sup>'
},
'h5': {
name: 'h5',
action: 'append-h5',
aria: 'header type five',
tagNames: ['h5'],
contentDefault: '<b>H5</b>',
contentFA: '<i class="fa fa-header"><sup>5</sup>'
},
'h6': {
name: 'h6',
action: 'append-h6',
aria: 'header type six',
tagNames: ['h6'],
contentDefault: '<b>H6</b>',
contentFA: '<i class="fa fa-header"><sup>6</sup>'
}
};
})();
(function () {
'use strict';
/* Base functionality for an extension which will display
* a 'form' inside the toolbar
*/
var FormExtension = MediumEditor.extensions.button.extend({
init: function () {
MediumEditor.extensions.button.prototype.init.apply(this, arguments);
},
// default labels for the form buttons
formSaveLabel: '&#10003;',
formCloseLabel: '&times;',
/* activeClass: [string]
* set class which added to shown form
*/
activeClass: 'medium-editor-toolbar-form-active',
/* 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: function () {},
/* isDisplayed: [function ()]
*
* This function should return true/false reflecting
* whether the form is currently displayed
*/
isDisplayed: function () {
if (this.hasForm) {
return this.getForm().classList.contains(this.activeClass);
}
return false;
},
/* hideForm: [function ()]
*
* This function should show the form element inside
* the toolbar container
*/
showForm: function () {
if (this.hasForm) {
this.getForm().classList.add(this.activeClass);
}
},
/* hideForm: [function ()]
*
* This function should hide the form element inside
* the toolbar container
*/
hideForm: function () {
if (this.hasForm) {
this.getForm().classList.remove(this.activeClass);
}
},
/************************ 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();
}
},
/* 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();
}
},
/* 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();
}
}
});
MediumEditor.extensions.form = FormExtension;
})();
(function () {
'use strict';
var AnchorForm = MediumEditor.extensions.form.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 () {
MediumEditor.extensions.form.prototype.init.apply(this, arguments);
this.subscribe('editableKeydown', this.handleKeydown.bind(this));
},
// Called when the button the toolbar is clicked
// Overrides ButtonExtension.handleClick
handleClick: function (event) {
event.preventDefault();
event.stopPropagation();
var range = MediumEditor.selection.getSelectionRange(this.document);
if (range.startContainer.nodeName.toLowerCase() === 'a' ||
range.endContainer.nodeName.toLowerCase() === 'a' ||
MediumEditor.util.getClosestTag(MediumEditor.selection.getSelectedParentElement(range), 'a')) {
return this.execAction('unlink');
}
if (!this.isDisplayed()) {
this.showForm();
}
return false;
},
// Called when user hits the defined shortcut (CTRL / COMMAND + K)
handleKeydown: function (event) {
if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.K) && MediumEditor.util.isMetaCtrlKey(event) && !event.shiftKey) {
this.handleClick(event);
}
},
// Called by medium-editor to append form to the toolbar
getForm: function () {
if (!this.form) {
this.form = this.createForm();
}
return this.form;
},
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(
'<div class="medium-editor-toolbar-form-row">',
'<input type="checkbox" class="medium-editor-toolbar-anchor-target" id="medium-editor-toolbar-anchor-target-field-' + this.getEditorId() + '">',
'<label for="medium-editor-toolbar-anchor-target-field-' + this.getEditorId() + '">',
this.targetCheckboxText,
'</label>',
'</div>'
);
}
if (this.customClassOption) {
// fixme: expose this `Button` text as a formLabel property, too
// and provide similar access to a `fa-` icon default.
template.push(
'<div class="medium-editor-toolbar-form-row">',
'<input type="checkbox" class="medium-editor-toolbar-anchor-button">',
'<label>',
this.customClassOptionText,
'</label>',
'</div>'
);
}
return template.join('');
},
// Used by medium-editor when the default toolbar is to be displayed
isDisplayed: function () {
return MediumEditor.extensions.form.prototype.isDisplayed.apply(this);
},
hideForm: function () {
MediumEditor.extensions.form.prototype.hideForm.apply(this);
this.getInput().value = '';
},
showForm: function (opts) {
var input = this.getInput(),
targetCheckbox = this.getAnchorTargetCheckbox(),
buttonCheckbox = this.getAnchorButtonCheckbox();
opts = opts || { value: '' };
// TODO: This is for backwards compatability
// We don't need to support the 'string' argument in 6.0.0
if (typeof opts === 'string') {
opts = {
value: opts
};
}
this.base.saveSelection();
this.hideToolbarDefaultActions();
MediumEditor.extensions.form.prototype.showForm.apply(this);
this.setToolbarPosition();
input.value = opts.value;
input.focus();
// If we have a target checkbox, we want it to be checked/unchecked
// based on whether the existing link has target=_blank
if (targetCheckbox) {
targetCheckbox.checked = opts.target === '_blank';
}
// If we have a custom class checkbox, we want it to be checked/unchecked
// based on whether an existing link already has the class
if (buttonCheckbox) {
var classList = opts.buttonClass ? opts.buttonClass.split(' ') : [];
buttonCheckbox.checked = (classList.indexOf(this.customClassOption) !== -1);
}
},
// Called by core when tearing down medium-editor (destroy)
destroy: function () {
if (!this.form) {
return false;
}
if (this.form.parentNode) {
this.form.parentNode.removeChild(this.form);
}
delete this.form;
},
// core methods
getFormOpts: function () {
// no notion of private functions? wanted `_getFormOpts`
var targetCheckbox = this.getAnchorTargetCheckbox(),
buttonCheckbox = this.getAnchorButtonCheckbox(),
opts = {
value: this.getInput().value.trim()
};
if (this.linkValidation) {
opts.value = this.checkLinkFormat(opts.value);
}
opts.target = '_self';
if (targetCheckbox && targetCheckbox.checked) {
opts.target = '_blank';
}
if (buttonCheckbox && buttonCheckbox.checked) {
opts.buttonClass = this.customClassOption;
}
return opts;
},
doFormSave: function () {
var opts = this.getFormOpts();
this.completeFormSave(opts);
},
completeFormSave: function (opts) {
this.base.restoreSelection();
this.execAction(this.action, opts);
this.base.checkSelection();
},
ensureEncodedUri: function (str) {
return str === decodeURI(str) ? encodeURI(str) : str;
},
ensureEncodedUriComponent: function (str) {
return str === decodeURIComponent(str) ? encodeURIComponent(str) : str;
},
ensureEncodedParam: function (param) {
var split = param.split('='),
key = split[0],
val = split[1];
return key + (val === undefined ? '' : '=' + this.ensureEncodedUriComponent(val));
},
ensureEncodedQuery: function (queryString) {
return queryString.split('&').map(this.ensureEncodedParam.bind(this)).join('&');
},
checkLinkFormat: function (value) {
// Matches any alphabetical characters followed by ://
// Matches protocol relative "//"
// Matches common external protocols "mailto:" "tel:" "maps:"
// Matches relative hash link, begins with "#"
var urlSchemeRegex = /^([a-z]+:)?\/\/|^(mailto|tel|maps):|^\#/i,
hasScheme = urlSchemeRegex.test(value),
scheme = '',
// telRegex is a regex for checking if the string is a telephone number
telRegex = /^\+?\s?\(?(?:\d\s?\-?\)?){3,20}$/,
urlParts = value.match(/^(.*?)(?:\?(.*?))?(?:#(.*))?$/),
path = urlParts[1],
query = urlParts[2],
fragment = urlParts[3];
if (telRegex.test(value)) {
return 'tel:' + value;
}
if (!hasScheme) {
var host = path.split('/')[0];
// if the host part of the path looks like a hostname
if (host.match(/.+(\.|:).+/) || host === 'localhost') {
scheme = 'http://';
}
}
return scheme +
// Ensure path is encoded
this.ensureEncodedUri(path) +
// Ensure query is encoded
(query === undefined ? '' : '?' + this.ensureEncodedQuery(query)) +
// Include fragment unencoded as encodeUriComponent is too
// heavy handed for the many characters allowed in a fragment
(fragment === undefined ? '' : '#' + fragment);
},
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');
},
getAnchorTargetCheckbox: function () {
return this.getForm().querySelector('.medium-editor-toolbar-anchor-target');
},
getAnchorButtonCheckbox: function () {
return this.getForm().querySelector('.medium-editor-toolbar-anchor-button');
},
handleTextboxKeyup: function (event) {
// For ENTER -> create the anchor
if (event.keyCode === MediumEditor.util.keyCode.ENTER) {
event.preventDefault();
this.doFormSave();
return;
}
// For ESCAPE -> close the form
if (event.keyCode === MediumEditor.util.keyCode.ESCAPE) {
event.preventDefault();
this.doFormCancel();
}
},
handleFormClick: function (event) {
// make sure not to hide form when clicking inside the form
event.stopPropagation();
},
handleSaveClick: function (event) {
// Clicking Save -> create the anchor
event.preventDefault();
this.doFormSave();
},
handleCloseClick: function (event) {
// Click Close -> close the form
event.preventDefault();
this.doFormCancel();
}
});
MediumEditor.extensions.anchor = AnchorForm;
}());
(function () {
'use strict';
var AnchorPreview = MediumEditor.Extension.extend({
name: 'anchor-preview',
// Anchor Preview Options
/* hideDelay: [number] (previously options.anchorPreviewHideDelay)
* time in milliseconds to show the anchor tag preview after the mouse has left the anchor tag.
*/
hideDelay: 500,
/* previewValueSelector: [string]
* the default selector to locate where to put the activeAnchor value in the preview
*/
previewValueSelector: 'a',
/* showWhenToolbarIsVisible: [boolean]
* determines whether the anchor tag preview shows up when the toolbar is visible
*/
showWhenToolbarIsVisible: false,
/* showOnEmptyLinks: [boolean]
* determines whether the anchor tag preview shows up on links with href="" or href="#something"
*/
showOnEmptyLinks: true,
init: function () {
this.anchorPreview = this.createPreview();
this.getEditorOption('elementsContainer').appendChild(this.anchorPreview);
this.attachToEditables();
},
getInteractionElements: function () {
return this.getPreviewElement();
},
// TODO: Remove this function in 6.0.0
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);
}
delete this.anchorPreview;
}
},
hidePreview: function () {
if (this.anchorPreview) {
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;
},
positionPreview: function (activeAnchor) {
activeAnchor = activeAnchor || this.activeAnchor;
var containerWidth = this.window.innerWidth,
buttonHeight = this.anchorPreview.offsetHeight,
boundary = activeAnchor.getBoundingClientRect(),
diffLeft = this.diffLeft,
diffTop = this.diffTop,
elementsContainer = this.getEditorOption('elementsContainer'),
elementsContainerAbsolute = ['absolute', 'fixed'].indexOf(window.getComputedStyle(elementsContainer).getPropertyValue('position')) > -1,
relativeBoundary = {},
halfOffsetWidth, defaultLeft, middleBoundary, elementsContainerBoundary, top;
halfOffsetWidth = this.anchorPreview.offsetWidth / 2;
var toolbarExtension = this.base.getExtensionByName('toolbar');
if (toolbarExtension) {
diffLeft = toolbarExtension.diffLeft;
diffTop = toolbarExtension.diffTop;
}
defaultLeft = diffLeft - halfOffsetWidth;
// If container element is absolute / fixed, recalculate boundaries to be relative to the container
if (elementsContainerAbsolute) {
elementsContainerBoundary = elementsContainer.getBoundingClientRect();
['top', 'left'].forEach(function (key) {
relativeBoundary[key] = boundary[key] - elementsContainerBoundary[key];
});
relativeBoundary.width = boundary.width;
relativeBoundary.height = boundary.height;
boundary = relativeBoundary;
containerWidth = elementsContainerBoundary.width;
// Adjust top position according to container scroll position
top = elementsContainer.scrollTop;
} else {
// Adjust top position according to window scroll position
top = this.window.pageYOffset;
}
middleBoundary = boundary.left + boundary.width / 2;
top += buttonHeight + boundary.top + boundary.height - diffTop - this.anchorPreview.offsetHeight;
this.anchorPreview.style.top = Math.round(top) + 'px';
this.anchorPreview.style.right = 'initial';
if (middleBoundary < halfOffsetWidth) {
this.anchorPreview.style.left = defaultLeft + halfOffsetWidth + 'px';
this.anchorPreview.style.right = 'initial';
} else if ((containerWidth - middleBoundary) < halfOffsetWidth) {
this.anchorPreview.style.left = 'auto';
this.anchorPreview.style.right = 0;
} else {
this.anchorPreview.style.left = defaultLeft + middleBoundary + 'px';
this.anchorPreview.style.right = 'initial';
}
},
attachToEditables: function () {
this.subscribe('editableMouseover', this.handleEditableMouseover.bind(this));
this.subscribe('positionedToolbar', this.handlePositionedToolbar.bind(this));
},
handlePositionedToolbar: function () {
// If the toolbar is visible and positioned, we don't need to hide the preview
// when showWhenToolbarIsVisible is true
if (!this.showWhenToolbarIsVisible) {
this.hidePreview();
}
},
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) {
var opts = {
value: activeAnchor.attributes.href.value,
target: activeAnchor.getAttribute('target'),
buttonClass: activeAnchor.getAttribute('class')
};
anchorExtension.showForm(opts);
activeAnchor = null;
}
}.bind(this));
}
this.hidePreview();
},
handleAnchorMouseout: function () {
this.anchorToPreview = null;
this.off(this.activeAnchor, 'mouseout', this.instanceHandleAnchorMouseout);
this.instanceHandleAnchorMouseout = null;
},
handleEditableMouseover: function (event) {
var target = MediumEditor.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.target.href, so check the html
if (!this.showOnEmptyLinks &&
(!/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 (!this.showWhenToolbarIsVisible && 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));
},
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;
}
},
updatePreview: function () {
if (this.hovering) {
return true;
}
var durr = (new Date()).getTime() - this.lastOver;
if (durr > this.hideDelay) {
// hide the preview 1/2 second after mouse leaves the link
this.detachPreviewHandlers();
}
},
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);
}
}
this.hidePreview();
this.hovering = this.instanceHandlePreviewMouseover = this.instanceHandlePreviewMouseout = null;
},
// 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);
}
});
MediumEditor.extensions.anchorPreview = AnchorPreview;
}());
(function () {
'use strict';
var WHITESPACE_CHARS,
KNOWN_TLDS_FRAGMENT,
LINK_REGEXP_TEXT,
KNOWN_TLDS_REGEXP,
LINK_REGEXP;
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 + '))';
KNOWN_TLDS_REGEXP = new RegExp('^(' + KNOWN_TLDS_FRAGMENT + ')$', 'i');
LINK_REGEXP = new RegExp(LINK_REGEXP_TEXT, 'gi');
function nodeIsNotInsideAnchorTag(node) {
return !MediumEditor.util.getClosestTag(node, 'a');
}
var AutoLink = MediumEditor.Extension.extend({
init: function () {
MediumEditor.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);
},
isLastInstance: function () {
var activeInstances = 0;
for (var i = 0; i < this.window._mediumEditors.length; i++) {
var editor = this.window._mediumEditors[i];
if (editor !== null && editor.getExtensionByName('autoLink') !== undefined) {
activeInstances++;
}
}
return activeInstances === 1;
},
destroy: function () {
// Turn AutoUrlDetect back on
if (this.document.queryCommandSupported('AutoUrlDetect') && this.isLastInstance()) {
this.document.execCommand('AutoUrlDetect', false, true);
}
},
onBlur: function (blurEvent, editable) {
this.performLinking(editable);
},
onKeypress: function (keyPressEvent) {
if (this.disableEventHandling) {
return;
}
if (MediumEditor.util.isKey(keyPressEvent, [MediumEditor.util.keyCode.SPACE, MediumEditor.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);
}
},
performLinking: function (contenteditable) {
/*
Perform linking on blockElement basis, blockElements are HTML elements with text content and without
child element.
Example:
- HTML content
<blockquote>
<p>link.</p>
<p>my</p>
</blockquote>
- blockElements
[<p>link.</p>, <p>my</p>]
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 blockElements - which doesn't work
and is terrible.
(Medium deletes the spaces/returns between P tags so the textContent ends up without paragraph spacing)
*/
var blockElements = MediumEditor.util.splitByBlockElements(contenteditable),
documentModified = false;
if (blockElements.length === 0) {
blockElements = [contenteditable];
}
for (var i = 0; i < blockElements.length; i++) {
documentModified = this.removeObsoleteAutoLinkSpans(blockElements[i]) || documentModified;
documentModified = this.performLinkingWithinElement(blockElements[i]) || documentModified;
}
this.base.events.updateInput(contenteditable, { target: contenteditable, currentTarget: contenteditable });
return documentModified;
},
removeObsoleteAutoLinkSpans: function (element) {
if (!element || element.nodeType === 3) {
return false;
}
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 = MediumEditor.util.ensureUrlHasProtocol(textContent);
}
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 = MediumEditor.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
MediumEditor.util.unwrap(spans[i], this.document);
}
}
}
return documentModified;
},
splitTextBeforeEnd: function (element, characterCount) {
var treeWalker = this.document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false),
lastChildNotExhausted = true;
// Start the tree walker at the last descendant of the span
while (lastChildNotExhausted) {
lastChildNotExhausted = treeWalker.lastChild() !== null;
}
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 previousNode;
},
performLinkingWithinElement: function (element) {
var matches = this.findLinkableText(element),
linkCreated = false;
for (var matchIndex = 0; matchIndex < matches.length; matchIndex++) {
var matchingTextNodes = MediumEditor.util.findOrCreateMatchingTextNodes(this.document, element,
matches[matchIndex]);
if (this.shouldNotLink(matchingTextNodes)) {
continue;
}
this.createAutoLink(matchingTextNodes, matches[matchIndex].href);
}
return linkCreated;
},
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 = !!MediumEditor.util.traverseUp(textNodes[i], function (node) {
return node.nodeName.toLowerCase() === 'a' ||
(node.getAttribute && node.getAttribute('data-auto-link') === 'true');
});
}
return shouldNotLink;
},
findLinkableText: function (contenteditable) {
var textContent = contenteditable.textContent,
match = null,
matches = [];
while ((match = LINK_REGEXP.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;
},
createAutoLink: function (textNodes, href) {
href = MediumEditor.util.ensureUrlHasProtocol(href);
var anchor = MediumEditor.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]);
}
}
});
MediumEditor.extensions.autoLink = AutoLink;
}());
(function () {
'use strict';
var CLASS_DRAG_OVER = 'medium-editor-dragover';
function clearClassNames(element) {
var editable = MediumEditor.util.getContainerEditorElement(element),
existing = Array.prototype.slice.call(editable.parentElement.querySelectorAll('.' + CLASS_DRAG_OVER));
existing.forEach(function (el) {
el.classList.remove(CLASS_DRAG_OVER);
});
}
var FileDragging = MediumEditor.Extension.extend({
name: 'fileDragging',
allowedTypes: ['image'],
init: function () {
MediumEditor.Extension.prototype.init.apply(this, arguments);
this.subscribe('editableDrag', this.handleDrag.bind(this));
this.subscribe('editableDrop', this.handleDrop.bind(this));
},
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);
}
},
handleDrop: function (event) {
// Prevent file from opening in the current window
event.preventDefault();
event.stopPropagation();
// Select the dropping target, and set the selection to the end of the target
// https://github.com/yabwe/medium-editor/issues/980
this.base.selectElement(event.target);
var selection = this.base.exportSelection();
selection.start = selection.end;
this.base.importSelection(selection);
// 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);
}
// Make sure we remove our class from everything
clearClassNames(event.target);
},
isAllowedFile: function (file) {
return this.allowedTypes.some(function (fileType) {
return !!file.type.match(fileType);
});
},
insertImageFile: function (file) {
if (typeof FileReader !== 'function') {
return;
}
var fileReader = new FileReader();
fileReader.readAsDataURL(file);
// attach the onload event handler, makes it easier to listen in with jasmine
fileReader.addEventListener('load', function (e) {
var addImageElement = this.document.createElement('img');
addImageElement.src = e.target.result;
MediumEditor.util.insertHTMLCommand(this.document, addImageElement.outerHTML);
}.bind(this));
}
});
MediumEditor.extensions.fileDragging = FileDragging;
}());
(function () {
'use strict';
var KeyboardCommands = MediumEditor.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 () {
MediumEditor.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);
},
handleKeydown: function (event) {
var keyCode = MediumEditor.util.getKeyCode(event);
if (!this.keys[keyCode]) {
return;
}
var isMeta = MediumEditor.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 ||
undefined === data.alt)) { // TODO deprecated: remove check for undefined === data.alt when jumping to 6.0.0
event.preventDefault();
event.stopPropagation();
// command can be a function to execute
if (typeof data.command === 'function') {
data.command.apply(this);
}
// command can be false so the shortcut is just disabled
else if (false !== data.command) {
this.execAction(data.command);
}
}
}, this);
}
});
MediumEditor.extensions.keyboardCommands = KeyboardCommands;
}());
(function () {
'use strict';
var FontNameForm = MediumEditor.extensions.form.extend({
name: 'fontname',
action: 'fontName',
aria: 'change font name',
contentDefault: '&#xB1;', // ±
contentFA: '<i class="fa fa-font"></i>',
fonts: ['', 'Arial', 'Verdana', 'Times New Roman'],
init: function () {
MediumEditor.extensions.form.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 FontName of current selection (convert to string since IE returns this as number)
var fontName = this.document.queryCommandValue('fontName') + '';
this.showForm(fontName);
}
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.getSelect().value = '';
},
showForm: function (fontName) {
var select = this.getSelect();
this.base.saveSelection();
this.hideToolbarDefaultActions();
this.getForm().style.display = 'block';
this.setToolbarPosition();
select.value = fontName || '';
select.focus();
},
// Called by core when tearing down medium-editor (destroy)
destroy: function () {
if (!this.form) {
return false;
}
if (this.form.parentNode) {
this.form.parentNode.removeChild(this.form);
}
delete this.form;
},
// core methods
doFormSave: function () {
this.base.restoreSelection();
this.base.checkSelection();
},
doFormCancel: function () {
this.base.restoreSelection();
this.clearFontName();
this.base.checkSelection();
},
// form creation and event handling
createForm: function () {
var doc = this.document,
form = doc.createElement('div'),
select = doc.createElement('select'),
close = doc.createElement('a'),
save = doc.createElement('a'),
option;
// Font Name Form (div)
form.className = 'medium-editor-toolbar-form';
form.id = 'medium-editor-toolbar-form-fontname-' + this.getEditorId();
// Handle clicks on the form itself
this.on(form, 'click', this.handleFormClick.bind(this));
// Add font names
for (var i = 0; i<this.fonts.length; i++) {
option = doc.createElement('option');
option.innerHTML = this.fonts[i];
option.value = this.fonts[i];
select.appendChild(option);
}
select.className = 'medium-editor-toolbar-select';
form.appendChild(select);
// Handle typing in the textbox
this.on(select, 'change', this.handleFontChange.bind(this));
// Add save buton
save.setAttribute('href', '#');
save.className = 'medium-editor-toobar-save';
save.innerHTML = this.getEditorOption('buttonLabels') === 'fontawesome' ?
'<i class="fa fa-check"></i>' :
'&#10003;';
form.appendChild(save);
// Handle save button clicks (capture)
this.on(save, 'click', this.handleSaveClick.bind(this), true);
// Add close button
close.setAttribute('href', '#');
close.className = 'medium-editor-toobar-close';
close.innerHTML = this.getEditorOption('buttonLabels') === 'fontawesome' ?
'<i class="fa fa-times"></i>' :
'&times;';
form.appendChild(close);
// Handle close button clicks
this.on(close, 'click', this.handleCloseClick.bind(this));
return form;
},
getSelect: function () {
return this.getForm().querySelector('select.medium-editor-toolbar-select');
},
clearFontName: function () {
MediumEditor.selection.getSelectedElements(this.document).forEach(function (el) {
if (el.nodeName.toLowerCase() === 'font' && el.hasAttribute('face')) {
el.removeAttribute('face');
}
});
},
handleFontChange: function () {
var font = this.getSelect().value;
if (font === '') {
this.clearFontName();
} else {
this.execAction('fontName', { value: font });
}
},
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();
}
});
MediumEditor.extensions.fontName = FontNameForm;
}());
(function () {
'use strict';
var FontSizeForm = MediumEditor.extensions.form.extend({
name: 'fontsize',
action: 'fontSize',
aria: 'increase/decrease font size',
contentDefault: '&#xB1;', // ±
contentFA: '<i class="fa fa-text-height"></i>',
init: function () {
MediumEditor.extensions.form.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();
input.value = fontSize || '';
input.focus();
},
// Called by core when tearing down medium-editor (destroy)
destroy: function () {
if (!this.form) {
return false;
}
if (this.form.parentNode) {
this.form.parentNode.removeChild(this.form);
}
delete this.form;
},
// core methods
doFormSave: function () {
this.base.restoreSelection();
this.base.checkSelection();
},
doFormCancel: function () {
this.base.restoreSelection();
this.clearFontSize();
this.base.checkSelection();
},
// form creation and event handling
createForm: function () {
var doc = this.document,
form = doc.createElement('div'),
input = doc.createElement('input'),
close = doc.createElement('a'),
save = doc.createElement('a');
// Font Size Form (div)
form.className = 'medium-editor-toolbar-form';
form.id = 'medium-editor-toolbar-form-fontsize-' + this.getEditorId();
// Handle clicks on the form itself
this.on(form, 'click', this.handleFormClick.bind(this));
// Add font size slider
input.setAttribute('type', 'range');
input.setAttribute('min', '1');
input.setAttribute('max', '7');
input.className = 'medium-editor-toolbar-input';
form.appendChild(input);
// Handle typing in the textbox
this.on(input, 'change', this.handleSliderChange.bind(this));
// Add save buton
save.setAttribute('href', '#');
save.className = 'medium-editor-toobar-save';
save.innerHTML = this.getEditorOption('buttonLabels') === 'fontawesome' ?
'<i class="fa fa-check"></i>' :
'&#10003;';
form.appendChild(save);
// Handle save button clicks (capture)
this.on(save, 'click', this.handleSaveClick.bind(this), true);
// Add close button
close.setAttribute('href', '#');
close.className = 'medium-editor-toobar-close';
close.innerHTML = this.getEditorOption('buttonLabels') === 'fontawesome' ?
'<i class="fa fa-times"></i>' :
'&times;';
form.appendChild(close);
// Handle close button clicks
this.on(close, 'click', this.handleCloseClick.bind(this));
return form;
},
getInput: function () {
return this.getForm().querySelector('input.medium-editor-toolbar-input');
},
clearFontSize: function () {
MediumEditor.selection.getSelectedElements(this.document).forEach(function (el) {
if (el.nodeName.toLowerCase() === 'font' && el.hasAttribute('size')) {
el.removeAttribute('size');
}
});
},
handleSliderChange: function () {
var size = this.getInput().value;
if (size === '4') {
this.clearFontSize();
} else {
this.execAction('fontSize', { value: size });
}
},
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();
}
});
MediumEditor.extensions.fontSize = FontSizeForm;
}());
(function () {
'use strict';
/* Helpers and internal variables that don't need to be members of actual paste handler */
var pasteBinDefaultContent = '%ME_PASTEBIN%',
lastRange = null,
keyboardPasteEditable = null,
stopProp = function (event) {
event.stopPropagation();
};
/*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 [
// Remove anything but the contents within the BODY element
[new RegExp(/^[\s\S]*<body[^>]*>\s*|\s*<\/body[^>]*>[\s\S]*$/g), ''],
// cleanup comments added by Chrome when pasting html
[new RegExp(/<!--StartFragment-->|<!--EndFragment-->/g), ''],
// Trailing BR elements
[new RegExp(/<br>$/i), ''],
// 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|700)|font-weight:(bold|700);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|700)[^>]*>/gi), '<span class="replace-with bold">'],
// replace manually entered b/i/a tags with real ones
[new RegExp(/&lt;(\/?)(i|b|a)&gt;/gi), '<$1$2>'],
// replace manually a tags with real ones, converting smart-quotes from google docs
[new RegExp(/&lt;a(?:(?!href).)+href=(?:&quot;|&rdquo;|&ldquo;|"|“|”)(((?!&quot;|&rdquo;|&ldquo;|"|“|”).)*)(?:&quot;|&rdquo;|&ldquo;|"|“|”)(?:(?!&gt;).)*&gt;/gi), '<a href="$1">'],
// Newlines between paragraphs in html have no syntactic value,
// but then have a tendency to accidentally become additional paragraphs down the line
[new RegExp(/<\/p>\n+/gi), '</p>'],
[new RegExp(/\n+<p/gi), '<p'],
// Microsoft Word makes these odd tags, like <o:p></o:p>
[new RegExp(/<\/?o:[a-z]*>/gi), ''],
// Microsoft Word adds some special elements around list items
[new RegExp(/<!\[if !supportLists\]>(((?!<!).)*)<!\[endif]\>/gi), '$1']
];
}
/*jslint regexp: false*/
/**
* Gets various content types out of the Clipboard API. It will also get the
* plain text using older IE and WebKit API.
*
* @param {event} event Event fired on paste.
* @param {win} reference to window
* @param {doc} reference to document
* @return {Object} Object with mime types and data for those mime types.
*/
function getClipboardContent(event, win, doc) {
var dataTransfer = event.clipboardData || win.clipboardData || doc.dataTransfer,
data = {};
if (!dataTransfer) {
return data;
}
// Use old WebKit/IE API
if (dataTransfer.getData) {
var legacyText = dataTransfer.getData('Text');
if (legacyText && legacyText.length > 0) {
data['text/plain'] = legacyText;
}
}
if (dataTransfer.types) {
for (var i = 0; i < dataTransfer.types.length; i++) {
var contentType = dataTransfer.types[i];
data[contentType] = dataTransfer.getData(contentType);
}
}
return data;
}
var PasteHandler = MediumEditor.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,
/* preCleanReplacements: [Array]
* custom pairs (2 element arrays) of RegExp and replacement text to use during past when
* __forcePlainText__ or __cleanPastedHTML__ are `true` OR when calling `cleanPaste(text)` helper method.
* These replacements are executed before any medium editor defined replacements.
*/
preCleanReplacements: [],
/* 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.
* These replacements are executed after any medium editor defined replacements.
*/
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'],
/* unwrapTags: [Array]
* list of element tag names to unwrap (remove the element tag but retain its child elements)
* during paste when __cleanPastedHTML__ is `true` or when
* calling `cleanPaste(text)` or `pasteHTML(html, options)` helper methods.
*/
unwrapTags: [],
init: function () {
MediumEditor.Extension.prototype.init.apply(this, arguments);
if (this.forcePlainText || this.cleanPastedHTML) {
this.subscribe('editableKeydown', this.handleKeydown.bind(this));
// We need access to the full event data in paste
// so we can't use the editablePaste event here
this.getEditorElements().forEach(function (element) {
this.on(element, 'paste', this.handlePaste.bind(this));
}, this);
this.subscribe('addElement', this.handleAddElement.bind(this));
}
},
handleAddElement: function (event, editable) {
this.on(editable, 'paste', this.handlePaste.bind(this));
},
destroy: function () {
// Make sure pastebin is destroyed in case it's still around for some reason
if (this.forcePlainText || this.cleanPastedHTML) {
this.removePasteBin();
}
},
handlePaste: function (event, editable) {
if (event.defaultPrevented) {
return;
}
var clipboardContent = getClipboardContent(event, this.window, this.document),
pastedHTML = clipboardContent['text/html'],
pastedPlain = clipboardContent['text/plain'];
if (this.window.clipboardData && event.clipboardData === undefined && !pastedHTML) {
// 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'.
//
// For IE, we'll fallback to 'Text' for text/html
pastedHTML = pastedPlain;
}
if (pastedHTML || pastedPlain) {
event.preventDefault();
this.doPaste(pastedHTML, pastedPlain, editable);
}
},
doPaste: function (pastedHTML, pastedPlain, editable) {
var paragraphs,
html = '',
p;
if (this.cleanPastedHTML && pastedHTML) {
return this.cleanPaste(pastedHTML);
}
if (!pastedPlain) {
return;
}
if (!(this.getEditorOption('disableReturn') || (editable && editable.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>' + MediumEditor.util.htmlEntities(paragraphs[p]) + '</p>';
}
}
} else {
html = MediumEditor.util.htmlEntities(paragraphs[0]);
}
} else {
html = MediumEditor.util.htmlEntities(pastedPlain);
}
MediumEditor.util.insertHTMLCommand(this.document, html);
},
handlePasteBinPaste: function (event) {
if (event.defaultPrevented) {
this.removePasteBin();
return;
}
var clipboardContent = getClipboardContent(event, this.window, this.document),
pastedHTML = clipboardContent['text/html'],
pastedPlain = clipboardContent['text/plain'],
editable = keyboardPasteEditable;
// If we have valid html already, or we're not in cleanPastedHTML mode
// we can ignore the paste bin and just paste now
if (!this.cleanPastedHTML || pastedHTML) {
event.preventDefault();
this.removePasteBin();
this.doPaste(pastedHTML, pastedPlain, editable);
// The event handling code listens for paste on the editable element
// in order to trigger the editablePaste event. Since this paste event
// is happening on the pastebin, the event handling code never knows about it
// So, we have to trigger editablePaste manually
this.trigger('editablePaste', { currentTarget: editable, target: editable }, editable);
return;
}
// We need to look at the paste bin, so do a setTimeout to let the paste
// fall through into the paste bin
setTimeout(function () {
// Only look for HTML if we're in cleanPastedHTML mode
if (this.cleanPastedHTML) {
// If clipboard didn't have HTML, try the paste bin
pastedHTML = this.getPasteBinHtml();
}
// If we needed the paste bin, we're done with it now, remove it
this.removePasteBin();
// Handle the paste with the html from the paste bin
this.doPaste(pastedHTML, pastedPlain, editable);
// The event handling code listens for paste on the editable element
// in order to trigger the editablePaste event. Since this paste event
// is happening on the pastebin, the event handling code never knows about it
// So, we have to trigger editablePaste manually
this.trigger('editablePaste', { currentTarget: editable, target: editable }, editable);
}.bind(this), 0);
},
handleKeydown: function (event, editable) {
// if it's not Ctrl+V, do nothing
if (!(MediumEditor.util.isKey(event, MediumEditor.util.keyCode.V) && MediumEditor.util.isMetaCtrlKey(event))) {
return;
}
event.stopImmediatePropagation();
this.removePasteBin();
this.createPasteBin(editable);
},
createPasteBin: function (editable) {
var rects,
range = MediumEditor.selection.getSelectionRange(this.document),
top = this.window.pageYOffset;
keyboardPasteEditable = editable;
if (range) {
rects = range.getClientRects();
// on empty line, rects is empty so we grab information from the first container of the range
if (rects.length) {
top += rects[0].top;
} else if (range.startContainer.getBoundingClientRect !== undefined) {
top += range.startContainer.getBoundingClientRect().top;
} else {
top += range.getBoundingClientRect().top;
}
}
lastRange = range;
var pasteBinElm = this.document.createElement('div');
pasteBinElm.id = this.pasteBinId = 'medium-editor-pastebin-' + (+Date.now());
pasteBinElm.setAttribute('style', 'border: 1px red solid; position: absolute; top: ' + top + 'px; width: 10px; height: 10px; overflow: hidden; opacity: 0');
pasteBinElm.setAttribute('contentEditable', true);
pasteBinElm.innerHTML = pasteBinDefaultContent;
this.document.body.appendChild(pasteBinElm);
// avoid .focus() to stop other event (actually the paste event)
this.on(pasteBinElm, 'focus', stopProp);
this.on(pasteBinElm, 'focusin', stopProp);
this.on(pasteBinElm, 'focusout', stopProp);
pasteBinElm.focus();
MediumEditor.selection.selectNode(pasteBinElm, this.document);
if (!this.boundHandlePaste) {
this.boundHandlePaste = this.handlePasteBinPaste.bind(this);
}
this.on(pasteBinElm, 'paste', this.boundHandlePaste);
},
removePasteBin: function () {
if (null !== lastRange) {
MediumEditor.selection.selectRange(this.document, lastRange);
lastRange = null;
}
if (null !== keyboardPasteEditable) {
keyboardPasteEditable = null;
}
var pasteBinElm = this.getPasteBin();
if (!pasteBinElm) {
return;
}
if (pasteBinElm) {
this.off(pasteBinElm, 'focus', stopProp);
this.off(pasteBinElm, 'focusin', stopProp);
this.off(pasteBinElm, 'focusout', stopProp);
this.off(pasteBinElm, 'paste', this.boundHandlePaste);
pasteBinElm.parentElement.removeChild(pasteBinElm);
}
},
getPasteBin: function () {
return this.document.getElementById(this.pasteBinId);
},
getPasteBinHtml: function () {
var pasteBinElm = this.getPasteBin();
if (!pasteBinElm) {
return false;
}
// WebKit has a nice bug where it clones the paste bin if you paste from for example notepad
// so we need to force plain text mode in this case
if (pasteBinElm.firstChild && pasteBinElm.firstChild.id === 'mcepastebin') {
return false;
}
var pasteBinHtml = pasteBinElm.innerHTML;
// If paste bin is empty try using plain text mode
// since that is better than nothing right
if (!pasteBinHtml || pasteBinHtml === pasteBinDefaultContent) {
return false;
}
return pasteBinHtml;
},
cleanPaste: function (text) {
var i, elList, tmp, workEl,
multiline = /<p|<br|<div/.test(text),
replacements = [].concat(
this.preCleanReplacements || [],
createReplacements(),
this.cleanReplacements || []);
for (i = 0; i < replacements.length; i += 1) {
text = text.replace(replacements[i][0], replacements[i][1]);
}
if (!multiline) {
return this.pasteHTML(text);
}
// create a temporary div to cleanup block elements
tmp = this.document.createElement('div');
// double br's aren't converted to p tags, but we want paragraphs.
tmp.innerHTML = '<p>' + text.split('<br><br>').join('</p><p>') + '</p>';
// block element cleanup
elList = tmp.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;
}
}
this.pasteHTML(tmp.innerHTML);
},
pasteHTML: function (html, options) {
options = MediumEditor.util.defaults({}, options, {
cleanAttrs: this.cleanAttrs,
cleanTags: this.cleanTags,
unwrapTags: this.unwrapTags
});
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')) {
MediumEditor.util.setTargetBlank(workEl);
}
MediumEditor.util.cleanupAttrs(workEl, options.cleanAttrs);
MediumEditor.util.cleanupTags(workEl, options.cleanTags);
MediumEditor.util.unwrapTags(workEl, options.unwrapTags);
}
MediumEditor.util.insertHTMLCommand(this.document, fragmentBody.innerHTML.replace(/&nbsp;/g, ' '));
},
// TODO (6.0): Make this an internal helper instead of member of paste handler
isCommonBlock: function (el) {
return (el && (el.nodeName.toLowerCase() === 'p' || el.nodeName.toLowerCase() === 'div'));
},
// TODO (6.0): Make this an internal helper instead of member of paste handler
filterCommonBlocks: function (el) {
if (/^\s*$/.test(el.textContent) && el.parentNode) {
el.parentNode.removeChild(el);
}
},
// TODO (6.0): Make this an internal helper instead of member of paste handler
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);
}
},
// TODO (6.0): Make this an internal helper instead of member of paste handler
// 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);
}
}
},
// TODO (6.0): Make this an internal helper instead of member of paste handler
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);
}
spans = containerEl.querySelectorAll('span');
for (i = 0; i < spans.length; i += 1) {
el = spans[i];
// bail if span is in contenteditable = false
if (MediumEditor.util.traverseUp(el, isCEF)) {
return false;
}
// remove empty spans, replace others with their contents
MediumEditor.util.unwrap(el, this.document);
}
}
});
MediumEditor.extensions.paste = PasteHandler;
}());
(function () {
'use strict';
var Placeholder = MediumEditor.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 () {
MediumEditor.Extension.prototype.init.apply(this, arguments);
this.initPlaceholders();
this.attachEventHandlers();
},
initPlaceholders: function () {
this.getEditorElements().forEach(this.initElement, this);
},
handleAddElement: function (event, editable) {
this.initElement(editable);
},
initElement: function (el) {
if (!el.getAttribute('data-placeholder')) {
el.setAttribute('data-placeholder', this.text);
}
this.updatePlaceholder(el);
},
destroy: function () {
this.getEditorElements().forEach(this.cleanupElement, this);
},
handleRemoveElement: function (event, editable) {
this.cleanupElement(editable);
},
cleanupElement: function (el) {
if (el.getAttribute('data-placeholder') === this.text) {
el.removeAttribute('data-placeholder');
}
},
showPlaceholder: function (el) {
if (el) {
// https://github.com/yabwe/medium-editor/issues/234
// In firefox, styling the placeholder with an absolutely positioned
// pseudo element causes the cursor to appear in a bad location
// when the element is completely empty, so apply a different class to
// style it with a relatively positioned pseudo element
if (MediumEditor.util.isFF && el.childNodes.length === 0) {
el.classList.add('medium-editor-placeholder-relative');
el.classList.remove('medium-editor-placeholder');
} else {
el.classList.add('medium-editor-placeholder');
el.classList.remove('medium-editor-placeholder-relative');
}
}
},
hidePlaceholder: function (el) {
if (el) {
el.classList.remove('medium-editor-placeholder');
el.classList.remove('medium-editor-placeholder-relative');
}
},
updatePlaceholder: function (el, dontShow) {
// If the element has content, hide the placeholder
if (el.querySelector('img, blockquote, ul, ol, table') || (el.textContent.replace(/^\s+|\s+$/g, '') !== '')) {
return this.hidePlaceholder(el);
}
if (!dontShow) {
this.showPlaceholder(el);
}
},
attachEventHandlers: function () {
if (this.hideOnClick) {
// For the 'hideOnClick' option, the placeholder should always be hidden on focus
this.subscribe('focus', this.handleFocus.bind(this));
}
// If the editor has content, it should always hide the placeholder
this.subscribe('editableInput', this.handleInput.bind(this));
// When the editor loses focus, check if the placeholder should be visible
this.subscribe('blur', this.handleBlur.bind(this));
// Need to know when elements are added/removed from the editor
this.subscribe('addElement', this.handleAddElement.bind(this));
this.subscribe('removeElement', this.handleRemoveElement.bind(this));
},
handleInput: function (event, element) {
// If the placeholder should be hidden on focus and the
// element has focus, don't show the placeholder
var dontShow = this.hideOnClick && (element === this.base.getFocusedElement());
// Editor's content has changed, check if the placeholder should be hidden
this.updatePlaceholder(element, dontShow);
},
handleFocus: function (event, element) {
// Editor has focus, hide the placeholder
this.hidePlaceholder(element);
},
handleBlur: function (event, element) {
// Editor has lost focus, check if the placeholder should be shown
this.updatePlaceholder(element);
}
});
MediumEditor.extensions.placeholder = Placeholder;
}());
(function () {
'use strict';
var Toolbar = MediumEditor.Extension.extend({
name: 'toolbar',
/* Toolbar Options */
/* align: ['left'|'center'|'right']
* When the __static__ option is true, this aligns the static toolbar
* relative to the medium-editor element.
*/
align: 'center',
/* 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,
/* stickyTopOffset: [Number]
* Value in pixel of the top offset above the toolbar
*/
stickyTopOffset: 0,
/* 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,
/* relativeContainer: [node]
* appending the toolbar to a given node instead of body
*/
relativeContainer: null,
init: function () {
MediumEditor.Extension.prototype.init.apply(this, arguments);
this.initThrottledMethods();
if (!this.relativeContainer) {
this.getEditorOption('elementsContainer').appendChild(this.getToolbarElement());
} else {
this.relativeContainer.appendChild(this.getToolbarElement());
}
},
// 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);
},
// Toolbar creation/deletion
createToolbar: function () {
var toolbar = this.document.createElement('div');
toolbar.id = 'medium-editor-toolbar-' + this.getEditorId();
toolbar.className = 'medium-editor-toolbar';
if (this.static) {
toolbar.className += ' static-toolbar';
} else if (this.relativeContainer) {
toolbar.className += ' medium-editor-relative-toolbar';
} else {
toolbar.className += ' medium-editor-stalker-toolbar';
}
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;
},
createToolbarButtons: function () {
var ul = this.document.createElement('ul'),
li,
btn,
buttons,
extension,
buttonName,
buttonOpts;
ul.id = 'medium-editor-toolbar-actions' + this.getEditorId();
ul.className = 'medium-editor-toolbar-actions';
ul.style.display = 'block';
this.buttons.forEach(function (button) {
if (typeof button === 'string') {
buttonName = button;
buttonOpts = null;
} else {
buttonName = button.name;
buttonOpts = button;
}
// 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 (MediumEditor.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;
},
destroy: function () {
if (this.toolbar) {
if (this.toolbar.parentNode) {
this.toolbar.parentNode.removeChild(this.toolbar);
}
delete this.toolbar;
}
},
// Toolbar accessors
getInteractionElements: function () {
return this.getToolbarElement();
},
getToolbarElement: function () {
if (!this.toolbar) {
this.toolbar = this.createToolbar();
}
return this.toolbar;
},
getToolbarActionsElement: function () {
return this.getToolbarElement().querySelector('.medium-editor-toolbar-actions');
},
// 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 = MediumEditor.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));
},
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 &&
MediumEditor.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();
}
});
},
// 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('<(' + MediumEditor.util.blockContainerElementNames.join('|') + ')[^>]*>', 'g'),
selectionHTML = MediumEditor.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 = MediumEditor.util.findAdjacentTextNodeWithContent(MediumEditor.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 = MediumEditor.selection.select(this.document, adjacentNode, offset,
selectionRange.endContainer, selectionRange.endOffset);
}
}
},
checkState: function () {
if (this.base.preventSelectionUpdates) {
return;
}
// If no editable has focus OR selection is inside contenteditable = false
// hide toolbar
if (!this.base.getFocusedElement() ||
MediumEditor.selection.selectionInContentEditableFalse(this.window)) {
return this.hideToolbar();
}
// 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 = MediumEditor.selection.getSelectionElement(this.window);
if (!selectionElement ||
this.getEditorElements().indexOf(selectionElement) === -1 ||
selectionElement.getAttribute('data-disable-toolbar')) {
return this.hideToolbar();
}
// Now we know there's a focused editable with a selection
// If the updateOnEmptySelection option is true, show the toolbar
if (this.updateOnEmptySelection && this.static) {
return this.showAndUpdateToolbar();
}
// If we don't have a 'valid' selection -> hide toolbar
if (!MediumEditor.selection.selectionContainsContent(this.document) ||
(this.allowMultiParagraphSelection === false && this.multipleBlockElementsSelected())) {
return this.hideToolbar();
}
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 = MediumEditor.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;
}
// 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 = MediumEditor.selection.getSelectedParentElement(selectionRange);
// Make sure the selection parent isn't outside of the contenteditable
if (!this.getEditorElements().some(function (element) {
return MediumEditor.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 (MediumEditor.util.isMediumEditorElement(parentNode)) {
break;
}
parentNode = parentNode.parentNode;
}
},
// Positioning toolbar
positionToolbarIfShown: function () {
if (this.isDisplayed()) {
this.setToolbarPosition();
}
},
setToolbarPosition: function () {
var container = this.base.getFocusedElement(),
selection = this.window.getSelection();
// If there isn't a valid selection, bail
if (!container) {
return this;
}
if (this.static || !selection.isCollapsed) {
this.showToolbar();
// we don't need any absolute positioning if relativeContainer is set
if (!this.relativeContainer) {
if (this.static) {
this.positionStaticToolbar(container);
} else {
this.positionToolbar(selection);
}
}
this.trigger('positionedToolbar', {}, this.base.getFocusedElement());
}
},
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 - this.stickyTopOffset)) {
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 - this.stickyTopOffset)) {
toolbarElement.classList.add('medium-editor-sticky-toolbar');
toolbarElement.style.top = this.stickyTopOffset + 'px';
// 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';
}
switch (this.align) {
case 'left':
targetLeft = containerRect.left;
break;
case 'right':
targetLeft = containerRect.right - toolbarWidth;
break;
case 'center':
targetLeft = containerCenter - halfOffsetWidth;
break;
}
if (targetLeft < 0) {
targetLeft = 0;
} else if ((targetLeft + toolbarWidth) > windowWidth) {
targetLeft = (windowWidth - Math.ceil(toolbarWidth) - 1);
}
toolbarElement.style.left = targetLeft + 'px';
},
positionToolbar: function (selection) {
// position the toolbar at left 0, so we can get the real width of the toolbar
this.getToolbarElement().style.left = '0';
this.getToolbarElement().style.right = 'initial';
var range = selection.getRangeAt(0),
boundary = range.getBoundingClientRect();
// Handle selections with just images
if (!boundary || ((boundary.height === 0 && boundary.width === 0) && range.startContainer === range.endContainer)) {
// If there's a nested image, use that for the bounding rectangle
if (range.startContainer.nodeType === 1 && range.startContainer.querySelector('img')) {
boundary = range.startContainer.querySelector('img').getBoundingClientRect();
} else {
boundary = range.startContainer.getBoundingClientRect();
}
}
var containerWidth = this.window.innerWidth,
toolbarElement = this.getToolbarElement(),
toolbarHeight = toolbarElement.offsetHeight,
toolbarWidth = toolbarElement.offsetWidth,
halfOffsetWidth = toolbarWidth / 2,
buttonHeight = 50,
defaultLeft = this.diffLeft - halfOffsetWidth,
elementsContainer = this.getEditorOption('elementsContainer'),
elementsContainerAbsolute = ['absolute', 'fixed'].indexOf(window.getComputedStyle(elementsContainer).getPropertyValue('position')) > -1,
positions = {},
relativeBoundary = {},
middleBoundary, elementsContainerBoundary;
// If container element is absolute / fixed, recalculate boundaries to be relative to the container
if (elementsContainerAbsolute) {
elementsContainerBoundary = elementsContainer.getBoundingClientRect();
['top', 'left'].forEach(function (key) {
relativeBoundary[key] = boundary[key] - elementsContainerBoundary[key];
});
relativeBoundary.width = boundary.width;
relativeBoundary.height = boundary.height;
boundary = relativeBoundary;
containerWidth = elementsContainerBoundary.width;
// Adjust top position according to container scroll position
positions.top = elementsContainer.scrollTop;
} else {
// Adjust top position according to window scroll position
positions.top = this.window.pageYOffset;
}
middleBoundary = boundary.left + boundary.width / 2;
positions.top += boundary.top - toolbarHeight;
if (boundary.top < buttonHeight) {
toolbarElement.classList.add('medium-toolbar-arrow-over');
toolbarElement.classList.remove('medium-toolbar-arrow-under');
positions.top += buttonHeight + boundary.height - this.diffTop;
} else {
toolbarElement.classList.add('medium-toolbar-arrow-under');
toolbarElement.classList.remove('medium-toolbar-arrow-over');
positions.top += this.diffTop;
}
if (middleBoundary < halfOffsetWidth) {
positions.left = defaultLeft + halfOffsetWidth;
positions.right = 'initial';
} else if ((containerWidth - middleBoundary) < halfOffsetWidth) {
positions.left = 'auto';
positions.right = 0;
} else {
positions.left = defaultLeft + middleBoundary;
positions.right = 'initial';
}
['top', 'left', 'right'].forEach(function (key) {
toolbarElement.style[key] = positions[key] + (isNaN(positions[key]) ? '' : 'px');
});
}
});
MediumEditor.extensions.toolbar = Toolbar;
}());
(function () {
'use strict';
var ImageDragging = MediumEditor.Extension.extend({
init: function () {
MediumEditor.Extension.prototype.init.apply(this, arguments);
this.subscribe('editableDrag', this.handleDrag.bind(this));
this.subscribe('editableDrop', this.handleDrop.bind(this));
},
handleDrag: function (event) {
var className = 'medium-editor-dragover';
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
if (event.type === 'dragover') {
event.target.classList.add(className);
} else if (event.type === 'dragleave') {
event.target.classList.remove(className);
}
},
handleDrop: function (event) {
var className = 'medium-editor-dragover',
files;
event.preventDefault();
event.stopPropagation();
// 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);
id = 'medium-img-' + (+new Date());
MediumEditor.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);
}
}.bind(this));
}
event.target.classList.remove(className);
}
});
MediumEditor.extensions.imageDragging = ImageDragging;
}());
(function () {
'use strict';
// Event handlers that shouldn't be exposed externally
function handleDisableExtraSpaces(event) {
var node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument),
textContent = node.textContent,
caretPositions = MediumEditor.selection.getCaretOffsets(node);
if ((textContent[caretPositions.left - 1] === undefined) || (textContent[caretPositions.left - 1].trim() === '') || (textContent[caretPositions.left] !== undefined && textContent[caretPositions.left].trim() === '')) {
event.preventDefault();
}
}
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 = MediumEditor.selection.getSelectionStart(this.options.ownerDocument);
// if current text selection is empty OR previous sibling text is empty OR it is not a list
if ((node && node.textContent.trim() === '' && node.nodeName.toLowerCase() !== 'li') ||
(node.previousElementSibling && node.previousElementSibling.nodeName.toLowerCase() !== 'br' &&
node.previousElementSibling.textContent.trim() === '')) {
event.preventDefault();
}
}
}
function handleTabKeydown(event) {
// Override tab only for pre nodes
var node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument),
tag = node && node.nodeName.toLowerCase();
if (tag === 'pre') {
event.preventDefault();
MediumEditor.util.insertHTMLCommand(this.options.ownerDocument, ' ');
}
// Tab to indent list structures!
if (MediumEditor.util.isListItem(node)) {
event.preventDefault();
// 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);
}
}
}
function handleBlockDeleteKeydowns(event) {
var p, node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument),
tagName = node.nodeName.toLowerCase(),
isEmpty = /^(\s+|<br\/?>)?$/i,
isHeader = /h\d/i;
if (MediumEditor.util.isKey(event, [MediumEditor.util.keyCode.BACKSPACE, MediumEditor.util.keyCode.ENTER]) &&
// has a preceeding sibling
node.previousElementSibling &&
// in a header
isHeader.test(tagName) &&
// at the very end of the block
MediumEditor.selection.getCaretOffsets(node).left === 0) {
if (MediumEditor.util.isKey(event, MediumEditor.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 (!this.options.disableDoubleReturn && MediumEditor.util.isKey(event, MediumEditor.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 (MediumEditor.util.isKey(event, MediumEditor.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
// remove node and move cursor to start of header
MediumEditor.selection.moveCursor(this.options.ownerDocument, node.nextElementSibling);
node.previousElementSibling.parentNode.removeChild(node);
event.preventDefault();
} else if (MediumEditor.util.isKey(event, MediumEditor.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
// create a paragraph before the list
p = this.options.ownerDocument.createElement('p');
p.innerHTML = '<br>';
node.parentElement.parentElement.insertBefore(p, node.parentElement);
// move the cursor into the new paragraph
MediumEditor.selection.moveCursor(this.options.ownerDocument, p);
// remove the list element
node.parentElement.removeChild(node);
event.preventDefault();
} else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.BACKSPACE) &&
(MediumEditor.util.getClosestTag(node, 'blockquote') !== false) &&
MediumEditor.selection.getCaretOffsets(node).left === 0) {
// when cursor is at the begining of the element and the element is <blockquote>
// then pressing backspace key should change the <blockquote> to a <p> tag
event.preventDefault();
MediumEditor.util.execFormatBlock(this.options.ownerDocument, 'p');
} else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER) &&
(MediumEditor.util.getClosestTag(node, 'blockquote') !== false) &&
MediumEditor.selection.getCaretOffsets(node).right === 0) {
// when cursor is at the end of <blockquote>,
// then pressing enter key should create <p> tag, not <blockquote>
p = this.options.ownerDocument.createElement('p');
p.innerHTML = '<br>';
node.parentElement.insertBefore(p, node.nextSibling);
// move the cursor into the new paragraph
MediumEditor.selection.moveCursor(this.options.ownerDocument, p);
event.preventDefault();
} else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.BACKSPACE) &&
MediumEditor.util.isMediumEditorElement(node.parentElement) &&
!node.previousElementSibling &&
node.nextElementSibling &&
isEmpty.test(node.innerHTML)) {
// when cursor is in the first element, it's empty and user presses backspace,
// do delete action instead to get rid of the first element and move caret to 2nd
event.preventDefault();
MediumEditor.selection.moveCursor(this.options.ownerDocument, node.nextSibling);
node.parentElement.removeChild(node);
}
}
function handleKeyup(event) {
var node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument),
tagName;
if (!node) {
return;
}
// https://github.com/yabwe/medium-editor/issues/994
// Firefox thrown an error when calling `formatBlock` on an empty editable blockContainer that's not a <div>
if (MediumEditor.util.isMediumEditorElement(node) && node.children.length === 0 && !MediumEditor.util.isBlockContainer(node)) {
this.options.ownerDocument.execCommand('formatBlock', false, 'p');
}
// https://github.com/yabwe/medium-editor/issues/834
// https://github.com/yabwe/medium-editor/pull/382
// Don't call format block if this is a block element (ie h1, figCaption, etc.)
if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER) &&
!MediumEditor.util.isListItem(node) &&
!MediumEditor.util.isBlockContainer(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) {
this.options.ownerDocument.execCommand('formatBlock', false, 'p');
}
}
}
function handleEditableInput(event, editable) {
var textarea = editable.parentNode.querySelector('textarea[medium-editor-textarea-id="' + editable.getAttribute('medium-editor-textarea-id') + '"]');
if (textarea) {
textarea.value = editable.innerHTML.trim();
}
}
// Internal helper methods which shouldn't be exposed externally
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];
}
// If this already has a unique id, re-use it
if (!this.id) {
this.id = win._mediumEditors.length;
}
win._mediumEditors[this.id] = this;
}
function removeFromEditors(win) {
if (!win._mediumEditors || !win._mediumEditors[this.id]) {
return;
}
/* 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, doc, filterEditorElements) {
var elements = [];
if (!selector) {
selector = [];
}
// If string, use as query selector
if (typeof selector === 'string') {
selector = doc.querySelectorAll(selector);
}
// If element, put into array
if (MediumEditor.util.isElement(selector)) {
selector = [selector];
}
if (filterEditorElements) {
// Remove elements that have already been initialized by the editor
// selecotr might not be an array (ie NodeList) so use for loop
for (var i = 0; i < selector.length; i++) {
var el = selector[i];
if (MediumEditor.util.isElement(el) &&
!el.getAttribute('data-medium-editor-element') &&
!el.getAttribute('medium-editor-textarea-id')) {
elements.push(el);
}
}
} else {
// Convert NodeList (or other array like object) into an array
elements = Array.prototype.slice.apply(selector);
}
return elements;
}
function cleanupTextareaElement(element) {
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');
textarea.removeAttribute('medium-editor-textarea-id');
}
if (element.parentNode) {
element.parentNode.removeChild(element);
}
}
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) {
var div = this.options.ownerDocument.createElement('div'),
now = Date.now(),
uniqueId = 'medium-editor-' + now,
atts = textarea.attributes;
// Some browsers can move pretty fast, since we're using a timestamp
// to make a unique-id, ensure that the id is actually unique on the page
while (this.options.ownerDocument.getElementById(uniqueId)) {
now++;
uniqueId = 'medium-editor-' + now;
}
div.className = textarea.className;
div.id = uniqueId;
div.innerHTML = textarea.value;
textarea.setAttribute('medium-editor-textarea-id', uniqueId);
// re-create all attributes from the textearea to the new created div
for (var i = 0, n = atts.length; i < n; i++) {
// do not re-create existing attributes
if (!div.hasAttribute(atts[i].nodeName)) {
div.setAttribute(atts[i].nodeName, atts[i].value);
}
}
// If textarea has a form, listen for reset on the form to clear
// the content of the created div
if (textarea.form) {
this.on(textarea.form, 'reset', function (event) {
if (!event.defaultPrevented) {
this.resetContent(this.options.ownerDocument.getElementById(uniqueId));
}
}.bind(this));
}
textarea.classList.add('medium-editor-hidden');
textarea.parentNode.insertBefore(
div,
textarea
);
return div;
}
function initElement(element, editorId) {
if (!element.getAttribute('data-medium-editor-element')) {
if (element.nodeName.toLowerCase() === 'textarea') {
element = createContentEditable.call(this, element);
// Make sure we only attach to editableInput once for <textarea> elements
if (!this.instanceHandleEditableInput) {
this.instanceHandleEditableInput = handleEditableInput.bind(this);
this.subscribe('editableInput', this.instanceHandleEditableInput);
}
}
if (!this.options.disableEditing && !element.getAttribute('data-disable-editing')) {
element.setAttribute('contentEditable', true);
element.setAttribute('spellcheck', this.options.spellcheck);
}
// Make sure we only attach to editableKeydownEnter once for disable-return options
if (!this.instanceHandleEditableKeydownEnter) {
if (element.getAttribute('data-disable-return') || element.getAttribute('data-disable-double-return')) {
this.instanceHandleEditableKeydownEnter = handleDisabledEnterKeydown.bind(this);
this.subscribe('editableKeydownEnter', this.instanceHandleEditableKeydownEnter);
}
}
// if we're not disabling return, add a handler to help handle cleanup
// for certain cases when enter is pressed
if (!this.options.disableReturn && !element.getAttribute('data-disable-return')) {
this.on(element, 'keyup', handleKeyup.bind(this));
}
var elementId = MediumEditor.util.guid();
element.setAttribute('data-medium-editor-element', true);
element.classList.add('medium-editor-element');
element.setAttribute('role', 'textbox');
element.setAttribute('aria-multiline', true);
element.setAttribute('data-medium-editor-editor-index', editorId);
// TODO: Merge data-medium-editor-element and medium-editor-index attributes for 6.0.0
// medium-editor-index is not named correctly anymore and can be re-purposed to signify
// whether the element has been initialized or not
element.setAttribute('medium-editor-index', elementId);
initialContent[elementId] = element.innerHTML;
this.events.attachAllEventsToElement(element);
}
return element;
}
function attachHandlers() {
// 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));
// Bind double space event
if (this.options.disableExtraSpaces) {
this.subscribe('editableKeydownSpace', handleDisableExtraSpaces.bind(this));
}
// Make sure we only attach to editableKeydownEnter once for disable-return options
if (!this.instanceHandleEditableKeydownEnter) {
// disabling return or double return
if (this.options.disableReturn || this.options.disableDoubleReturn) {
this.instanceHandleEditableKeydownEnter = handleDisabledEnterKeydown.bind(this);
this.subscribe('editableKeydownEnter', this.instanceHandleEditableKeydownEnter);
}
}
}
function initExtensions() {
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,
'anchor-preview': 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 = MediumEditor.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) {
MediumEditor.util.deprecated(pair[0], pair[1], 'v6.0.0');
}
});
}
return MediumEditor.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,
cmdValueArgument;
/*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 MediumEditor.util.execFormatBlock(this.options.ownerDocument, match[1]);
}
if (action === 'fontSize') {
// TODO: Deprecate support for opts.size in 6.0.0
if (opts.size) {
MediumEditor.util.deprecated('.size option for fontSize command', '.value', '6.0.0');
}
cmdValueArgument = opts.value || opts.size;
return this.options.ownerDocument.execCommand('fontSize', false, cmdValueArgument);
}
if (action === 'fontName') {
// TODO: Deprecate support for opts.name in 6.0.0
if (opts.name) {
MediumEditor.util.deprecated('.name option for fontName command', '.value', '6.0.0');
}
cmdValueArgument = opts.value || opts.name;
return this.options.ownerDocument.execCommand('fontName', false, cmdValueArgument);
}
if (action === 'createLink') {
return this.createLink(opts);
}
if (action === 'image') {
var src = this.options.contentWindow.getSelection().toString().trim();
return this.options.ownerDocument.execCommand('insertImage', false, src);
}
if (action === 'html') {
var html = this.options.contentWindow.getSelection().toString().trim();
return MediumEditor.util.insertHTMLCommand(this.options.ownerDocument, html);
}
/* 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 = MediumEditor.selection.getSelectedParentElement(MediumEditor.selection.getSelectionRange(this.options.ownerDocument));
if (parentNode) {
cleanupJustifyDivFragments.call(this, MediumEditor.util.getTopBlockContainer(parentNode));
}
return result;
}
cmdValueArgument = opts && opts.value;
return this.options.ownerDocument.execCommand(action, false, cmdValueArgument);
}
/* 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>
MediumEditor.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();
}
}
var initialContent = {};
MediumEditor.prototype = {
// 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();
},
setup: function () {
if (this.isActive) {
return;
}
addToEditors.call(this, this.options.contentWindow);
this.events = new MediumEditor.Events(this);
this.elements = [];
this.addElements(this.origElements);
if (this.elements.length === 0) {
return;
}
this.isActive = true;
// Call initialization helpers
initExtensions.call(this);
attachHandlers.call(this);
},
destroy: function () {
if (!this.isActive) {
return;
}
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.classList.remove('medium-editor-element');
element.removeAttribute('role');
element.removeAttribute('aria-multiline');
element.removeAttribute('medium-editor-index');
element.removeAttribute('data-medium-editor-editor-index');
// Remove any elements created for textareas
if (element.getAttribute('medium-editor-textarea-id')) {
cleanupTextareaElement(element);
}
}, this);
this.elements = [];
this.instanceHandleEditableKeydownEnter = null;
this.instanceHandleEditableInput = null;
removeFromEditors.call(this, this.options.contentWindow);
},
on: function (target, event, listener, useCapture) {
this.events.attachDOMEvent(target, event, listener, useCapture);
return this;
},
off: function (target, event, listener, useCapture) {
this.events.detachDOMEvent(target, event, listener, useCapture);
return this;
},
subscribe: function (event, listener) {
this.events.attachCustomEvent(event, listener);
return this;
},
unsubscribe: function (event, listener) {
this.events.detachCustomEvent(event, listener);
return this;
},
trigger: function (name, data, editable) {
this.events.triggerCustomEvent(name, data, editable);
return this;
},
delay: function (fn) {
var self = this;
return setTimeout(function () {
if (self.isActive) {
fn();
}
}, this.options.delay);
},
serialize: function () {
var i,
elementid,
content = {},
len = this.elements.length;
for (i = 0; i < len; i += 1) {
elementid = (this.elements[i].id !== '') ? this.elements[i].id : 'element-' + i;
content[elementid] = {
value: this.elements[i].innerHTML.trim()
};
}
return content;
},
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;
},
/**
* 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 = MediumEditor.util.extend({}, this.options.anchor, opts);
extension = new MediumEditor.extensions.anchor(merged);
break;
case 'anchor-preview':
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 'fontname':
extension = new MediumEditor.extensions.fontName(this.options.fontName);
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 = MediumEditor.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;
},
stopSelectionUpdates: function () {
this.preventSelectionUpdates = true;
},
startSelectionUpdates: function () {
this.preventSelectionUpdates = false;
},
checkSelection: function () {
var toolbar = this.getExtensionByName('toolbar');
if (toolbar) {
toolbar.checkState();
}
return this;
},
// 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;
// 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;
},
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') {
MediumEditor.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 MediumEditor.selection.getSelectedParentElement(range);
},
selectAllContents: function () {
var currNode = MediumEditor.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);
}
},
selectElement: function (element) {
MediumEditor.selection.selectNode(element, this.options.ownerDocument);
var selElement = MediumEditor.selection.getSelectionElement(this.options.contentWindow);
if (selElement) {
this.events.focusElement(selElement);
}
},
getFocusedElement: function () {
var focused;
this.elements.some(function (element) {
// Find the element that has focus
if (!focused && element.getAttribute('data-medium-focused')) {
focused = element;
}
// bail if we found the element that had focus
return !!focused;
}, this);
return focused;
},
// Export the state of the selection in respect to one of this
// instance of MediumEditor's elements
exportSelection: function () {
var selectionElement = MediumEditor.selection.getSelectionElement(this.options.contentWindow),
editableElementIndex = this.elements.indexOf(selectionElement),
selectionState = null;
if (editableElementIndex >= 0) {
selectionState = MediumEditor.selection.exportSelection(selectionElement, this.options.ownerDocument);
}
if (selectionState !== null && editableElementIndex !== 0) {
selectionState.editableElementIndex = editableElementIndex;
}
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;
}
var editableElement = this.elements[selectionState.editableElementIndex || 0];
MediumEditor.selection.importSelection(selectionState, editableElement, this.options.ownerDocument, favorLaterSelectionAnchor);
},
restoreSelection: function () {
this.importSelection(this.selectionState);
},
createLink: function (opts) {
var currentEditor = MediumEditor.selection.getSelectionElement(this.options.contentWindow),
customEvent = {},
targetUrl;
// Make sure the selection is within an element this editor is tracking
if (this.elements.indexOf(currentEditor) === -1) {
return;
}
try {
this.events.disableCustomEvent('editableInput');
// TODO: Deprecate support for opts.url in 6.0.0
if (opts.url) {
MediumEditor.util.deprecated('.url option for createLink', '.value', '6.0.0');
}
targetUrl = opts.url || opts.value;
if (targetUrl && targetUrl.trim().length > 0) {
var currentSelection = this.options.contentWindow.getSelection();
if (currentSelection) {
var currRange = currentSelection.getRangeAt(0),
commonAncestorContainer = currRange.commonAncestorContainer,
exportedSelection,
startContainerParentElement,
endContainerParentElement,
textNodes;
// If the selection is contained within a single text node
// and the selection starts at the beginning of the text node,
// MSIE still says the startContainer is the parent of the text node.
// If the selection is contained within a single text node, we
// want to just use the default browser 'createLink', so we need
// to account for this case and adjust the commonAncestorContainer accordingly
if (currRange.endContainer.nodeType === 3 &&
currRange.startContainer.nodeType !== 3 &&
currRange.startOffset === 0 &&
currRange.startContainer.firstChild === currRange.endContainer) {
commonAncestorContainer = currRange.endContainer;
}
startContainerParentElement = MediumEditor.util.getClosestBlockContainer(currRange.startContainer);
endContainerParentElement = MediumEditor.util.getClosestBlockContainer(currRange.endContainer);
// If the selection is not contained within a single text node
// but the selection is contained within the same block element
// we want to make sure we create a single link, and not multiple links
// which can happen with the built in browser functionality
if (commonAncestorContainer.nodeType !== 3 && commonAncestorContainer.textContent.length !== 0 && startContainerParentElement === endContainerParentElement) {
var parentElement = (startContainerParentElement || currentEditor),
fragment = this.options.ownerDocument.createDocumentFragment();
// since we are going to create a link from an extracted text,
// be sure that if we are updating a link, we won't let an empty link behind (see #754)
// (Workaroung for Chrome)
this.execAction('unlink');
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
MediumEditor.selection.select(
this.options.ownerDocument,
parentElement.firstChild,
0,
parentElement.lastChild,
parentElement.lastChild.nodeType === 3 ?
parentElement.lastChild.nodeValue.length : parentElement.lastChild.childNodes.length
);
} else {
MediumEditor.selection.select(
this.options.ownerDocument,
parentElement,
0,
parentElement,
parentElement.childNodes.length
);
}
var modifiedExportedSelection = this.exportSelection();
textNodes = MediumEditor.util.findOrCreateMatchingTextNodes(
this.options.ownerDocument,
fragment,
{
start: exportedSelection.start - modifiedExportedSelection.start,
end: exportedSelection.end - modifiedExportedSelection.start,
editableElementIndex: exportedSelection.editableElementIndex
}
);
// If textNodes are not present, when changing link on images
// ex: <a><img src="http://image.test.com"></a>, change fragment to currRange.startContainer
// and set textNodes array to [imageElement, imageElement]
if (textNodes.length === 0) {
fragment = this.options.ownerDocument.createDocumentFragment();
fragment.appendChild(commonAncestorContainer.cloneNode(true));
textNodes = [fragment.firstChild.firstChild, fragment.firstChild.lastChild];
}
// Creates the link in the document fragment
MediumEditor.util.createLink(this.options.ownerDocument, textNodes, targetUrl.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
MediumEditor.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, targetUrl);
}
if (this.options.targetBlank || opts.target === '_blank') {
MediumEditor.util.setTargetBlank(MediumEditor.selection.getSelectionStart(this.options.ownerDocument), targetUrl);
} else {
MediumEditor.util.removeTargetBlank(MediumEditor.selection.getSelectionStart(this.options.ownerDocument), targetUrl);
}
if (opts.buttonClass) {
MediumEditor.util.addClassToAnchors(MediumEditor.selection.getSelectionStart(this.options.ownerDocument), opts.buttonClass);
}
}
}
// Fire input event for backwards compatibility if anyone was listening directly to the DOM input event
if (this.options.targetBlank || opts.target === '_blank' || opts.buttonClass) {
customEvent = this.options.ownerDocument.createEvent('HTMLEvents');
customEvent.initEvent('input', true, true, this.options.contentWindow);
for (var i = 0, len = this.elements.length; i < len; i += 1) {
this.elements[i].dispatchEvent(customEvent);
}
}
} finally {
this.events.enableCustomEvent('editableInput');
}
// Fire our custom editableInput event
this.events.triggerCustomEvent('editableInput', customEvent, currentEditor);
},
cleanPaste: function (text) {
this.getExtensionByName('paste').cleanPaste(text);
},
pasteHTML: function (html, options) {
this.getExtensionByName('paste').pasteHTML(html, options);
},
setContent: function (html, index) {
index = index || 0;
if (this.elements[index]) {
var target = this.elements[index];
target.innerHTML = html;
this.checkContentChanged(target);
}
},
getContent: function (index) {
index = index || 0;
if (this.elements[index]) {
return this.elements[index].innerHTML.trim();
}
return null;
},
checkContentChanged: function (editable) {
editable = editable || MediumEditor.selection.getSelectionElement(this.options.contentWindow);
this.events.updateInput(editable, { target: editable, currentTarget: editable });
},
resetContent: function (element) {
// For all elements that exist in the this.elements array, we can assume:
// - Its initial content has been set in the initialContent object
// - It has a medium-editor-index attribute which is the key value in the initialContent object
if (element) {
var index = this.elements.indexOf(element);
if (index !== -1) {
this.setContent(initialContent[element.getAttribute('medium-editor-index')], index);
}
return;
}
this.elements.forEach(function (el, idx) {
this.setContent(initialContent[el.getAttribute('medium-editor-index')], idx);
}, this);
},
addElements: function (selector) {
// Convert elements into an array
var elements = createElementsArray(selector, this.options.ownerDocument, true);
// Do we have elements to add now?
if (elements.length === 0) {
return false;
}
elements.forEach(function (element) {
// Initialize all new elements (we check that in those functions don't worry)
element = initElement.call(this, element, this.id);
// Add new elements to our internal elements array
this.elements.push(element);
// Trigger event so extensions can know when an element has been added
this.trigger('addElement', { target: element, currentTarget: element }, element);
}, this);
},
removeElements: function (selector) {
// Convert elements into an array
var elements = createElementsArray(selector, this.options.ownerDocument),
toRemove = elements.map(function (el) {
// For textareas, make sure we're looking at the editor div and not the textarea itself
if (el.getAttribute('medium-editor-textarea-id') && el.parentNode) {
return el.parentNode.querySelector('div[medium-editor-textarea-id="' + el.getAttribute('medium-editor-textarea-id') + '"]');
} else {
return el;
}
});
this.elements = this.elements.filter(function (element) {
// If this is an element we want to remove
if (toRemove.indexOf(element) !== -1) {
this.events.cleanupElement(element);
if (element.getAttribute('medium-editor-textarea-id')) {
cleanupTextareaElement(element);
}
// Trigger event so extensions can clean-up elements that are being removed
this.trigger('removeElement', { target: element, currentTarget: element }, element);
return false;
}
return true;
}, this);
}
};
MediumEditor.getEditorFromElement = function (element) {
var index = element.getAttribute('data-medium-editor-editor-index'),
win = element && element.ownerDocument && (element.ownerDocument.defaultView || element.ownerDocument.parentWindow);
if (win && win._mediumEditors && win._mediumEditors[index]) {
return win._mediumEditors[index];
}
return null;
};
}());
(function () {
// summary: The default options hash used by the Editor
MediumEditor.prototype.defaults = {
activeButtonClass: 'medium-editor-button-active',
buttonLabels: false,
delay: 0,
disableReturn: false,
disableDoubleReturn: false,
disableExtraSpaces: false,
disableEditing: false,
autoLink: false,
elementsContainer: false,
contentWindow: window,
ownerDocument: document,
targetBlank: false,
extensions: {},
spellcheck: true
};
})();
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 : '');
}
};
};
MediumEditor.version = MediumEditor.parseVersionString.call(this, ({
// grunt-bump looks for this:
'version': '5.23.3'
}).version);
return MediumEditor;
}()));