* 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 = this.findCommonRoot(startNode, endNode);
if (!rootNode) {
return false;
}
if (endNode === startNode) {
var temp = startNode.parentNode,
sibling = startNode.nextSibling;
temp.removeChild(startNode);
newElement.appendChild(startNode);
if (sibling) {
temp.insertBefore(newElement, sibling);
} else {
temp.appendChild(newElement);
}
return newElement.hasChildNodes();
}
// create rootChildren array which includes all the children
// we care about
var rootChildren = [],
firstChild,
lastChild,
nextNode;
for (var i = 0; i < rootNode.childNodes.length; i++) {
nextNode = rootNode.childNodes[i];
if (!firstChild) {
if (this.isDescendant(nextNode, startNode, true)) {
firstChild = nextNode;
}
} else {
if (this.isDescendant(nextNode, endNode, true)) {
lastChild = nextNode;
break;
} else {
rootChildren.push(nextNode);
}
}
}
var afterLast = lastChild.nextSibling,
fragment = rootNode.ownerDocument.createDocumentFragment();
// build up fragment on startNode side of tree
if (firstChild === startNode) {
firstChild.parentNode.removeChild(firstChild);
fragment.appendChild(firstChild);
} else {
fragment.appendChild(this.splitOffDOMTree(firstChild, startNode));
}
// add any elements between firstChild & lastChild
rootChildren.forEach(function (element) {
element.parentNode.removeChild(element);
fragment.appendChild(element);
});
// build up fragment on endNode side of the tree
if (lastChild === endNode) {
lastChild.parentNode.removeChild(lastChild);
fragment.appendChild(lastChild);
} else {
fragment.appendChild(this.splitOffDOMTree(lastChild, endNode, true));
}
// Add fragment into passed in element
newElement.appendChild(fragment);
if (lastChild.parentNode === rootNode) {
// If last child is in the root, insert newElement in front of it
rootNode.insertBefore(newElement, lastChild);
} else if (afterLast) {
// If last child was removed, but it had a sibling, insert in front of it
rootNode.insertBefore(newElement, afterLast);
} else {
// lastChild was removed and was the last actual element just append
rootNode.appendChild(newElement);
}
return newElement.hasChildNodes();
},
/* 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 = this.depthOfNode(inNode1),
depth2 = this.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 (!this.isBlockContainer(node) && !this.isMediumEditorElement(node)) {
sibling = node;
while (sibling = sibling.previousSibling) {
textVal = sibling.nodeType === 3 ? sibling.nodeValue : sibling.textContent;
if (textVal.length > 0) {
return false;
}
}
node = node.parentNode;
}
return true;
},
isMediumEditorElement: function (element) {
return element && element.nodeType !== 3 && !!element.getAttribute('data-medium-element');
},
isBlockContainer: function (element) {
return element && element.nodeType !== 3 && this.parentElements.indexOf(element.nodeName.toLowerCase()) !== -1;
},
getClosestBlockContainer: function (node) {
return Util.traverseUp(node, function (node) {
return Util.isBlockContainer(node);
});
},
getBlockContainer: function (element) {
return this.traverseUp(element, function (el) {
return Util.isBlockContainer(el) && !Util.isBlockContainer(el.parentNode);
});
},
getFirstSelectableLeafNode: function (element) {
while (element && element.firstChild) {
element = element.firstChild;
}
var emptyElements = ['br', 'col', 'colgroup', 'hr', 'img', 'input', 'source', 'wbr'];
while (emptyElements.indexOf(element.nodeName.toLowerCase()) !== -1) {
// We don't want to set the selection to an element that can't have children, this messes up Gecko.
element = element.parentNode;
}
// Selecting at the beginning of a table doesn't work in PhantomJS.
if (element.nodeName.toLowerCase() === 'table') {
var firstCell = element.querySelector('th, td');
if (firstCell) {
element = firstCell;
}
}
return element;
},
getFirstTextNode: function (element) {
if (element.nodeType === 3) {
return element;
}
for (var i = 0; i < element.childNodes.length; i++) {
var textNode = this.getFirstTextNode(element.childNodes[i]);
if (textNode !== null) {
return textNode;
}
}
return null;
},
ensureUrlHasProtocol: function (url) {
if (url.indexOf('://') === -1) {
return 'http://' + url;
}
return url;
},
warn: function () {
if (window.console !== undefined && typeof window.console.warn === 'function') {
window.console.warn.apply(window.console, arguments);
}
},
deprecated: function (oldName, newName, version) {
// simple deprecation warning mechanism.
var m = oldName + ' is deprecated, please use ' + newName + ' instead.';
if (version) {
m += ' Will be removed in ' + version;
}
Util.warn(m);
},
deprecatedMethod: function (oldName, newName, args, version) {
// run the replacement and warn when someone calls a deprecated method
Util.deprecated(oldName, newName, version);
if (typeof this[newName] === 'function') {
this[newName].apply(this, args);
}
},
cleanupAttrs: function (el, attrs) {
attrs.forEach(function (attr) {
el.removeAttribute(attr);
});
},
cleanupTags: function (el, tags) {
tags.forEach(function (tag) {
if (el.tagName.toLowerCase() === tag) {
el.parentNode.removeChild(el);
}
}, this);
},
// get the closest parent
getClosestTag: function (el, tag) {
return this.traverseUp(el, function (element) {
return element.tagName.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);
}
},
setObject: function (name, value, context) {
// summary:
// Set a property from a dot-separated string, such as 'A.B.C'
var parts = name.split('.'),
p = parts.pop(),
obj = getProp(parts, true, context);
return obj && p ? (obj[p] = value) : undefined; // Object
},
getObject: function (name, create, context) {
// summary:
// Get a property from a dot-separated string, such as 'A.B.C'
return getProp(name ? name.split('.') : [], create, context); // Object
}
};
}(window));
var ButtonsData;
(function () {
'use strict';
ButtonsData = {
'bold': {
name: 'bold',
action: 'bold',
aria: 'bold',
tagNames: ['b', 'strong'],
style: {
prop: 'font-weight',
value: '700|bold'
},
useQueryState: true,
contentDefault: '
B ',
contentFA: '
',
key: 'B'
},
'italic': {
name: 'italic',
action: 'italic',
aria: 'italic',
tagNames: ['i', 'em'],
style: {
prop: 'font-style',
value: 'italic'
},
useQueryState: true,
contentDefault: '
I ',
contentFA: '
',
key: 'I'
},
'underline': {
name: 'underline',
action: 'underline',
aria: 'underline',
tagNames: ['u'],
style: {
prop: 'text-decoration',
value: 'underline'
},
useQueryState: true,
contentDefault: '
U ',
contentFA: '
',
key: 'U'
},
'strikethrough': {
name: 'strikethrough',
action: 'strikethrough',
aria: 'strike through',
tagNames: ['strike'],
style: {
prop: 'text-decoration',
value: 'line-through'
},
useQueryState: true,
contentDefault: '
A ',
contentFA: '
'
},
'superscript': {
name: 'superscript',
action: 'superscript',
aria: 'superscript',
tagNames: ['sup'],
/* firefox doesn't behave the way we want it to, so we CAN'T use queryCommandState for superscript
https://github.com/guardian/scribe/blob/master/BROWSERINCONSISTENCIES.md#documentquerycommandstate */
// useQueryState: true
contentDefault: '
x1 ',
contentFA: '
'
},
'subscript': {
name: 'subscript',
action: 'subscript',
aria: 'subscript',
tagNames: ['sub'],
/* firefox doesn't behave the way we want it to, so we CAN'T use queryCommandState for subscript
https://github.com/guardian/scribe/blob/master/BROWSERINCONSISTENCIES.md#documentquerycommandstate */
// useQueryState: true
contentDefault: '
x1 ',
contentFA: '
'
},
'image': {
name: 'image',
action: 'image',
aria: 'image',
tagNames: ['img'],
contentDefault: '
image ',
contentFA: '
'
},
'quote': {
name: 'quote',
action: 'append-blockquote',
aria: 'blockquote',
tagNames: ['blockquote'],
contentDefault: '
“ ',
contentFA: '
'
},
'orderedlist': {
name: 'orderedlist',
action: 'insertorderedlist',
aria: 'ordered list',
tagNames: ['ol'],
useQueryState: true,
contentDefault: '
1. ',
contentFA: '
'
},
'unorderedlist': {
name: 'unorderedlist',
action: 'insertunorderedlist',
aria: 'unordered list',
tagNames: ['ul'],
useQueryState: true,
contentDefault: '
• ',
contentFA: '
'
},
'pre': {
name: 'pre',
action: 'append-pre',
aria: 'preformatted text',
tagNames: ['pre'],
contentDefault: '
0101 ',
contentFA: '
'
},
'indent': {
name: 'indent',
action: 'indent',
aria: 'indent',
tagNames: [],
contentDefault: '
→ ',
contentFA: '
'
},
'outdent': {
name: 'outdent',
action: 'outdent',
aria: 'outdent',
tagNames: [],
contentDefault: '
← ',
contentFA: '
'
},
'justifyCenter': {
name: 'justifyCenter',
action: 'justifyCenter',
aria: 'center justify',
tagNames: [],
style: {
prop: 'text-align',
value: 'center'
},
contentDefault: '
C ',
contentFA: '
'
},
'justifyFull': {
name: 'justifyFull',
action: 'justifyFull',
aria: 'full justify',
tagNames: [],
style: {
prop: 'text-align',
value: 'justify'
},
contentDefault: '
J ',
contentFA: '
'
},
'justifyLeft': {
name: 'justifyLeft',
action: 'justifyLeft',
aria: 'left justify',
tagNames: [],
style: {
prop: 'text-align',
value: 'left'
},
contentDefault: '
L ',
contentFA: '
'
},
'justifyRight': {
name: 'justifyRight',
action: 'justifyRight',
aria: 'right justify',
tagNames: [],
style: {
prop: 'text-align',
value: 'right'
},
contentDefault: '
R ',
contentFA: '
'
},
'header1': {
name: 'header1',
action: function (options) {
return 'append-' + options.firstHeader;
},
aria: function (options) {
return options.firstHeader;
},
tagNames: function (options) {
return [options.firstHeader];
},
contentDefault: '
H1 '
},
'header2': {
name: 'header2',
action: function (options) {
return 'append-' + options.secondHeader;
},
aria: function (options) {
return options.secondHeader;
},
tagNames: function (options) {
return [options.secondHeader];
},
contentDefault: '
H2 '
},
// Known inline elements that are not removed, or not removed consistantly across browsers:
//
, ,
'removeFormat': {
name: 'removeFormat',
aria: 'remove formatting',
action: 'removeFormat',
contentDefault: 'X ',
contentFA: ' '
}
};
})();
var editorDefaults;
(function () {
// summary: The default options hash used by the Editor
editorDefaults = {
allowMultiParagraphSelection: true,
buttons: ['bold', 'italic', 'underline', 'anchor', 'header1', 'header2', 'quote'],
buttonLabels: false,
delay: 0,
diffLeft: 0,
diffTop: -10,
disableReturn: false,
disableDoubleReturn: false,
disableToolbar: false,
disableEditing: false,
autoLink: false,
toolbarAlign: 'center',
elementsContainer: false,
imageDragging: true,
standardizeSelectionStart: false,
contentWindow: window,
ownerDocument: document,
firstHeader: 'h3',
secondHeader: 'h4',
targetBlank: false,
extensions: {},
activeButtonClass: 'medium-editor-button-active',
firstButtonClass: 'medium-editor-button-first',
lastButtonClass: 'medium-editor-button-last',
spellcheck: true
};
})();
var Extension;
(function () {
'use strict';
/* global Util */
Extension = function (options) {
Util.extend(this, options);
};
Extension.extend = function (protoProps) {
// magic extender thinger. mostly borrowed from backbone/goog.inherits
// place this function on some thing you want extend-able.
//
// example:
//
// function Thing(args){
// this.options = args;
// }
//
// Thing.prototype = { foo: "bar" };
// Thing.extend = extenderify;
//
// var ThingTwo = Thing.extend({ foo: "baz" });
//
// var thingOne = new Thing(); // foo === "bar"
// var thingTwo = new ThingTwo(); // foo === "baz"
//
// which seems like some simply shallow copy nonsense
// at first, but a lot more is going on there.
//
// passing a `constructor` to the extend props
// will cause the instance to instantiate through that
// instead of the parent's constructor.
var parent = this,
child;
// The constructor function for the new subclass is either defined by you
// (the "constructor" property in your `extend` definition), or defaulted
// by us to simply call the parent's constructor.
if (protoProps && protoProps.hasOwnProperty('constructor')) {
child = protoProps.constructor;
} else {
child = function () {
return parent.apply(this, arguments);
};
}
// das statics (.extend comes over, so your subclass can have subclasses too)
Util.extend(child, parent);
// Set the prototype chain to inherit from `parent`, without calling
// `parent`'s constructor function.
var Surrogate = function () {
this.constructor = child;
};
Surrogate.prototype = parent.prototype;
child.prototype = new Surrogate();
if (protoProps) {
Util.extend(child.prototype, protoProps);
}
// todo: $super?
return child;
};
Extension.prototype = {
/* init: [function]
*
* Called by MediumEditor during initialization.
* The .base property will already have been set to
* current instance of MediumEditor when this is called.
* All helper methods will exist as well
*/
init: function () {},
/* base: [MediumEditor instance]
*
* If not overriden, this will be set to the current instance
* of MediumEditor, before the init method is called
*/
base: undefined,
/* name: [string]
*
* 'name' of the extension, used for retrieving the extension.
* If not set, MediumEditor will set this to be the key
* used when passing the extension into MediumEditor via the
* 'extensions' option
*/
name: undefined,
/* checkState: [function (node)]
*
* If implemented, this function will be called one or more times
* the state of the editor & toolbar are updated.
* When the state is updated, the editor does the following:
*
* 1) Find the parent node containing the current selection
* 2) Call checkState on the extension, passing the node as an argument
* 3) Get the parent node of the previous node
* 4) Repeat steps #2 and #3 until we move outside the parent contenteditable
*/
checkState: undefined,
/* destroy: [function ()]
*
* This method should remove any created html, custom event handlers
* or any other cleanup tasks that should be performed.
* If implemented, this function will be called when MediumEditor's
* destroy method has been called.
*/
destroy: undefined,
/* As alternatives to checkState, these functions provide a more structured
* path to updating the state of an extension (usually a button) whenever
* the state of the editor & toolbar are updated.
*/
/* queryCommandState: [function ()]
*
* If implemented, this function will be called once on each extension
* when the state of the editor/toolbar is being updated.
*
* If this function returns a non-null value, the extension will
* be ignored as the code climbs the dom tree.
*
* If this function returns true, and the setActive() function is defined
* setActive() will be called
*/
queryCommandState: undefined,
/* isActive: [function ()]
*
* If implemented, this function will be called when MediumEditor
* has determined that this extension is 'active' for the current selection.
* This may be called when the editor & toolbar are being updated,
* but only if queryCommandState() or isAlreadyApplied() functions
* are implemented, and when called, return true.
*/
isActive: undefined,
/* isAlreadyApplied: [function (node)]
*
* If implemented, this function is similar to checkState() in
* that it will be called repeatedly as MediumEditor moves up
* the DOM to update the editor & toolbar after a state change.
*
* NOTE: This function will NOT be called if checkState() has
* been implemented. This function will NOT be called if
* queryCommandState() is implemented and returns a non-null
* value when called
*/
isAlreadyApplied: undefined,
/* setActive: [function ()]
*
* If implemented, this function is called when MediumEditor knows
* that this extension is currently enabled. Currently, this
* function is called when updating the editor & toolbar, and
* only if queryCommandState() or isAlreadyApplied(node) return
* true when called
*/
setActive: undefined,
/* setInactive: [function ()]
*
* If implemented, this function is called when MediumEditor knows
* that this extension is currently disabled. Curently, this
* is called at the beginning of each state change for
* the editor & toolbar. After calling this, MediumEditor
* will attempt to update the extension, either via checkState()
* or the combination of queryCommandState(), isAlreadyApplied(node),
* isActive(), and setActive()
*/
setInactive: undefined,
/************************ Helpers ************************
* The following are helpers that are either set by MediumEditor
* during initialization, or are helper methods which either
* route calls to the MediumEditor instance or provide common
* functionality for all extensions
*********************************************************/
/* window: [Window]
*
* If not overriden, this will be set to the window object
* to be used by MediumEditor and its extensions. This is
* passed via the 'contentWindow' option to MediumEditor
* and is the global 'window' object by default
*/
'window': undefined,
/* document: [Document]
*
* If not overriden, this will be set to the document object
* to be used by MediumEditor and its extensions. This is
* passed via the 'ownerDocument' optin to MediumEditor
* and is the global 'document' object by default
*/
'document': undefined,
/* getEditorElements: [function ()]
*
* Helper function which returns an array containing
* all the contenteditable elements for this instance
* of MediumEditor
*/
getEditorElements: function () {
return this.base.elements;
},
/* getEditorId: [function ()]
*
* Helper function which returns a unique identifier
* for this instance of MediumEditor
*/
getEditorId: function () {
return this.base.id;
},
/* getEditorOptions: [function (option)]
*
* Helper function which returns the value of an option
* used to initialize this instance of MediumEditor
*/
getEditorOption: function (option) {
return this.base.options[option];
}
};
/* List of method names to add to the prototype of Extension
* Each of these methods will be defined as helpers that
* just call directly into the MediumEditor instance.
*
* example for 'on' method:
* Extension.prototype.on = function () {
* return this.base.on.apply(this.base, arguments);
* }
*/
[
// general helpers
'execAction',
// event handling
'on',
'off',
'subscribe'
].forEach(function (helper) {
Extension.prototype[helper] = function () {
return this.base[helper].apply(this.base, arguments);
};
});
})();
var Selection;
(function () {
'use strict';
function filterOnlyParentElements(node) {
if (Util.isBlockContainer(node)) {
return NodeFilter.FILTER_ACCEPT;
} else {
return NodeFilter.FILTER_SKIP;
}
}
Selection = {
findMatchingSelectionParent: function (testElementFunction, contentWindow) {
var selection = contentWindow.getSelection(),
range,
current;
if (selection.rangeCount === 0) {
return false;
}
range = selection.getRangeAt(0);
current = range.commonAncestorContainer;
return Util.traverseUp(current, testElementFunction);
},
getSelectionElement: function (contentWindow) {
return this.findMatchingSelectionParent(function (el) {
return el.getAttribute('data-medium-element');
}, contentWindow);
},
// Utility method called from importSelection only
importSelectionMoveCursorPastAnchor: function (selectionState, range) {
var nodeInsideAnchorTagFunction = function (node) {
return node.nodeName.toLowerCase() === 'a';
};
if (selectionState.start === selectionState.end &&
range.startContainer.nodeType === 3 &&
range.startOffset === range.startContainer.nodeValue.length &&
Util.traverseUp(range.startContainer, nodeInsideAnchorTagFunction)) {
var prevNode = range.startContainer,
currentNode = range.startContainer.parentNode;
while (currentNode !== null && currentNode.nodeName.toLowerCase() !== 'a') {
if (currentNode.childNodes[currentNode.childNodes.length - 1] !== prevNode) {
currentNode = null;
} else {
prevNode = currentNode;
currentNode = currentNode.parentNode;
}
}
if (currentNode !== null && currentNode.nodeName.toLowerCase() === 'a') {
var currentNodeIndex = null;
for (var i = 0; currentNodeIndex === null && i < currentNode.parentNode.childNodes.length; i++) {
if (currentNode.parentNode.childNodes[i] === currentNode) {
currentNodeIndex = i;
}
}
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 && Util.isBlockContainer(startContainer.previousSibling)) {
startBlock = startContainer.previousSibling;
} else {
startBlock = Util.getClosestBlockContainer(startContainer);
}
// Skip over empty blocks until we hit the block we want the selection to be in
while (treeWalker.nextNode()) {
if (!targetNode) {
// Loop through all blocks until we hit the starting block element
if (startBlock === treeWalker.currentNode) {
targetNode = treeWalker.currentNode;
}
} else {
targetNode = treeWalker.currentNode;
currIndex++;
// We hit the target index, bail
if (currIndex === index) {
break;
}
// If we find a non-empty block, ignore the emptyBlocksIndex and just put selection here
if (targetNode.textContent.length > 0) {
break;
}
}
}
// We're selecting a high-level block node, so make sure the cursor gets moved into the deepest
// element at the beginning of the block
range.setStart(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 && !Util.isElementAtBeginningOfBlock(node)) {
return -1;
}
// Walk over block elements, counting number of empty blocks between last piece of text
// and the block the cursor is in
var closestBlock = 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;
},
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() {
var i,
html = '',
sel = this.options.contentWindow.getSelection(),
len,
container;
if (sel.rangeCount) {
container = this.options.ownerDocument.createElement('div');
for (i = 0, len = sel.rangeCount; i < len; i += 1) {
container.appendChild(sel.getRangeAt(i).cloneContents());
}
html = container.innerHTML;
}
return html;
},
/**
* Find the caret position within an element irrespective of any inline tags it may contain.
*
* @param {DOMElement} An element containing the cursor to find offsets relative to.
* @param {Range} A Range representing cursor position. Will window.getSelection if none is passed.
* @return {Object} 'left' and 'right' attributes contain offsets from begining and end of Element
*/
getCaretOffsets: function getCaretOffsets(element, range) {
var preCaretRange, postCaretRange;
if (!range) {
range = window.getSelection().getRangeAt(0);
}
preCaretRange = range.cloneRange();
postCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(range.endContainer, range.endOffset);
postCaretRange.selectNodeContents(element);
postCaretRange.setStart(range.endContainer, range.endOffset);
return {
left: preCaretRange.toString().length,
right: postCaretRange.toString().length
};
},
// http://stackoverflow.com/questions/15867542/range-object-get-selection-parent-node-chrome-vs-firefox
rangeSelectsSingleNode: function (range) {
var startNode = range.startContainer;
return startNode === range.endContainer &&
startNode.hasChildNodes() &&
range.endOffset === range.startOffset + 1;
},
getSelectedParentElement: function (range) {
if (!range) {
return null;
}
// Selection encompasses a single element
if (this.rangeSelectsSingleNode(range) && range.startContainer.childNodes[range.startOffset].nodeType !== 3) {
return range.startContainer.childNodes[range.startOffset];
}
// Selection range starts inside a text node, so get its parent
if (range.startContainer.nodeType === 3) {
return range.startContainer.parentNode;
}
// Selection starts inside an element
return range.startContainer;
},
getSelectedElements: function (doc) {
var selection = doc.getSelection(),
range,
toRet,
currNode;
if (!selection.rangeCount || selection.isCollapsed || !selection.getRangeAt(0).commonAncestorContainer) {
return [];
}
range = selection.getRangeAt(0);
if (range.commonAncestorContainer.nodeType === 3) {
toRet = [];
currNode = range.commonAncestorContainer;
while (currNode.parentNode && currNode.parentNode.childNodes.length === 1) {
toRet.push(currNode.parentNode);
currNode = currNode.parentNode;
}
return toRet;
}
return [].filter.call(range.commonAncestorContainer.getElementsByTagName('*'), function (el) {
return (typeof selection.containsNode === 'function') ? selection.containsNode(el, true) : true;
});
},
selectNode: function (node, doc) {
var range = doc.createRange(),
sel = doc.getSelection();
range.selectNodeContents(node);
sel.removeAllRanges();
sel.addRange(range);
},
select: function (doc, startNode, startOffset, endNode, endOffset) {
doc.getSelection().removeAllRanges();
var range = doc.createRange();
range.setStart(startNode, startOffset);
if (endNode) {
range.setEnd(endNode, endOffset);
} else {
range.collapse(true);
}
doc.getSelection().addRange(range);
return range;
},
/**
* Move cursor to the given node with the given offset.
*
* @param {DomDocument} doc Current document
* @param {DomElement} node Element where to jump
* @param {integer} offset Where in the element should we jump, 0 by default
*/
moveCursor: function (doc, node, offset) {
var range, sel,
startOffset = offset || 0;
range = doc.createRange();
sel = doc.getSelection();
range.setStart(node, startOffset);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
},
getSelectionRange: function (ownerDocument) {
var selection = ownerDocument.getSelection();
if (selection.rangeCount === 0) {
return null;
}
return selection.getRangeAt(0);
},
// http://stackoverflow.com/questions/1197401/how-can-i-get-the-element-the-caret-is-in-with-javascript-when-using-contentedi
// by You
getSelectionStart: function (ownerDocument) {
var node = ownerDocument.getSelection().anchorNode,
startNode = (node && node.nodeType === 3 ? node.parentNode : node);
return startNode;
},
getSelectionData: function (el) {
var tagName;
if (el && el.tagName) {
tagName = el.tagName.toLowerCase();
}
while (el && !Util.isBlockContainer(el)) {
el = el.parentNode;
if (el && el.tagName) {
tagName = el.tagName.toLowerCase();
}
}
return {
el: el,
tagName: tagName
};
}
};
}());
var Events;
(function () {
'use strict';
Events = function (instance) {
this.base = instance;
this.options = this.base.options;
this.events = [];
this.disabledEvents = {};
this.customEvents = {};
this.listeners = {};
};
Events.prototype = {
InputEventOnContenteditableSupported: !Util.isIE,
// Helpers for event handling
attachDOMEvent: function (target, event, listener, useCapture) {
target.addEventListener(event, listener, useCapture);
this.events.push([target, event, listener, useCapture]);
},
detachDOMEvent: function (target, event, listener, useCapture) {
var index = this.indexOfListener(target, event, listener, useCapture),
e;
if (index !== -1) {
e = this.events.splice(index, 1)[0];
e[0].removeEventListener(e[1], e[2], e[3]);
}
},
indexOfListener: function (target, event, listener, useCapture) {
var i, n, item;
for (i = 0, n = this.events.length; i < n; i = i + 1) {
item = this.events[i];
if (item[0] === target && item[1] === event && item[2] === listener && item[3] === useCapture) {
return i;
}
}
return -1;
},
detachAllDOMEvents: function () {
var e = this.events.pop();
while (e) {
e[0].removeEventListener(e[1], e[2], e[3]);
e = this.events.pop();
}
},
enableCustomEvent: function (event) {
if (this.disabledEvents[event] !== undefined) {
delete this.disabledEvents[event];
}
},
disableCustomEvent: function (event) {
this.disabledEvents[event] = true;
},
// custom events
attachCustomEvent: function (event, listener) {
this.setupListener(event);
if (!this.customEvents[event]) {
this.customEvents[event] = [];
}
this.customEvents[event].push(listener);
},
detachCustomEvent: function (event, listener) {
var index = this.indexOfCustomListener(event, listener);
if (index !== -1) {
this.customEvents[event].splice(index, 1);
// TODO: If array is empty, should detach internal listeners via destroyListener()
}
},
indexOfCustomListener: function (event, listener) {
if (!this.customEvents[event] || !this.customEvents[event].length) {
return -1;
}
return this.customEvents[event].indexOf(listener);
},
detachAllCustomEvents: function () {
this.customEvents = {};
// TODO: Should detach internal listeners here via destroyListener()
},
triggerCustomEvent: function (name, data, editable) {
if (this.customEvents[name] && !this.disabledEvents[name]) {
this.customEvents[name].forEach(function (listener) {
listener(data, editable);
});
}
},
// Cleaning up
destroy: function () {
this.detachAllDOMEvents();
this.detachAllCustomEvents();
this.detachExecCommand();
if (this.base.elements) {
this.base.elements.forEach(function (element) {
element.removeAttribute('data-medium-focused');
});
}
},
// Listening to calls to document.execCommand
// Attach a listener to be notified when document.execCommand is called
attachToExecCommand: function () {
if (this.execCommandListener) {
return;
}
// Store an instance of the listener so:
// 1) We only attach to execCommand once
// 2) We can remove the listener later
this.execCommandListener = function (execInfo) {
this.handleDocumentExecCommand(execInfo);
}.bind(this);
// Ensure that execCommand has been wrapped correctly
this.wrapExecCommand();
// Add listener to list of execCommand listeners
this.options.ownerDocument.execCommand.listeners.push(this.execCommandListener);
},
// Remove our listener for calls to document.execCommand
detachExecCommand: function () {
var doc = this.options.ownerDocument;
if (!this.execCommandListener || !doc.execCommand.listeners) {
return;
}
// Find the index of this listener in the array of listeners so it can be removed
var index = doc.execCommand.listeners.indexOf(this.execCommandListener);
if (index !== -1) {
doc.execCommand.listeners.splice(index, 1);
}
// If the list of listeners is now empty, put execCommand back to its original state
if (!doc.execCommand.listeners.length) {
this.unwrapExecCommand();
}
},
// Wrap document.execCommand in a custom method so we can listen to calls to it
wrapExecCommand: function () {
var doc = this.options.ownerDocument;
// Ensure all instance of MediumEditor only wrap execCommand once
if (doc.execCommand.listeners) {
return;
}
// Create a wrapper method for execCommand which will:
// 1) Call document.execCommand with the correct arguments
// 2) Loop through any listeners and notify them that execCommand was called
// passing extra info on the call
// 3) Return the result
var wrapper = function (aCommandName, aShowDefaultUI, aValueArgument) {
var result = doc.execCommand.orig.apply(this, arguments);
if (!doc.execCommand.listeners) {
return result;
}
var args = Array.prototype.slice.call(arguments);
doc.execCommand.listeners.forEach(function (listener) {
listener({
command: aCommandName,
value: aValueArgument,
args: args,
result: result
});
});
return result;
};
// Store a reference to the original execCommand
wrapper.orig = doc.execCommand;
// Attach an array for storing listeners
wrapper.listeners = [];
// Overwrite execCommand
doc.execCommand = wrapper;
},
// Revert document.execCommand back to its original self
unwrapExecCommand: function () {
var doc = this.options.ownerDocument;
if (!doc.execCommand.orig) {
return;
}
// Use the reference to the original execCommand to revert back
doc.execCommand = doc.execCommand.orig;
},
// Listening to browser events to emit events medium-editor cares about
setupListener: function (name) {
if (this.listeners[name]) {
return;
}
switch (name) {
case 'externalInteraction':
// Detecting when user has interacted with elements outside of MediumEditor
this.attachDOMEvent(this.options.ownerDocument.body, 'mousedown', this.handleBodyMousedown.bind(this), true);
this.attachDOMEvent(this.options.ownerDocument.body, 'click', this.handleBodyClick.bind(this), true);
this.attachDOMEvent(this.options.ownerDocument.body, 'focus', this.handleBodyFocus.bind(this), true);
break;
case 'blur':
// Detecting when focus is lost
this.setupListener('externalInteraction');
break;
case 'focus':
// Detecting when focus moves into some part of MediumEditor
this.setupListener('externalInteraction');
break;
case 'editableInput':
// setup cache for knowing when the content has changed
this.contentCache = [];
this.base.elements.forEach(function (element) {
this.contentCache[element.getAttribute('medium-editor-index')] = element.innerHTML;
// Attach to the 'oninput' event, handled correctly by most browsers
if (this.InputEventOnContenteditableSupported) {
this.attachDOMEvent(element, 'input', this.handleInput.bind(this));
}
}.bind(this));
// For browsers which don't support the input event on contenteditable (IE)
// we'll attach to 'selectionchange' on the document and 'keypress' on the editables
if (!this.InputEventOnContenteditableSupported) {
this.setupListener('editableKeypress');
this.keypressUpdateInput = true;
this.attachDOMEvent(document, 'selectionchange', this.handleDocumentSelectionChange.bind(this));
// Listen to calls to execCommand
this.attachToExecCommand();
}
break;
case 'editableClick':
// Detecting click in the contenteditables
this.base.elements.forEach(function (element) {
this.attachDOMEvent(element, 'click', this.handleClick.bind(this));
}.bind(this));
break;
case 'editableBlur':
// Detecting blur in the contenteditables
this.base.elements.forEach(function (element) {
this.attachDOMEvent(element, 'blur', this.handleBlur.bind(this));
}.bind(this));
break;
case 'editableKeypress':
// Detecting keypress in the contenteditables
this.base.elements.forEach(function (element) {
this.attachDOMEvent(element, 'keypress', this.handleKeypress.bind(this));
}.bind(this));
break;
case 'editableKeyup':
// Detecting keyup in the contenteditables
this.base.elements.forEach(function (element) {
this.attachDOMEvent(element, 'keyup', this.handleKeyup.bind(this));
}.bind(this));
break;
case 'editableKeydown':
// Detecting keydown on the contenteditables
this.base.elements.forEach(function (element) {
this.attachDOMEvent(element, 'keydown', this.handleKeydown.bind(this));
}.bind(this));
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.base.elements.forEach(function (element) {
this.attachDOMEvent(element, 'mouseover', this.handleMouseover.bind(this));
}, this);
break;
case 'editableDrag':
// Detecting dragover and dragleave on the contenteditables
this.base.elements.forEach(function (element) {
this.attachDOMEvent(element, 'dragover', this.handleDragging.bind(this));
this.attachDOMEvent(element, 'dragleave', this.handleDragging.bind(this));
}, this);
break;
case 'editableDrop':
// Detecting drop on the contenteditables
this.base.elements.forEach(function (element) {
this.attachDOMEvent(element, 'drop', this.handleDrop.bind(this));
}, this);
break;
case 'editablePaste':
// Detecting paste on the contenteditables
this.base.elements.forEach(function (element) {
this.attachDOMEvent(element, 'paste', this.handlePaste.bind(this));
}, this);
break;
}
this.listeners[name] = true;
},
focusElement: function (element) {
element.focus();
this.updateFocus(element, { target: element, type: 'focus' });
},
updateFocus: function (target, eventObj) {
var toolbarEl = this.base.toolbar ? this.base.toolbar.getToolbarElement() : null,
anchorPreview = this.base.getExtensionByName('anchor-preview'),
previewEl = (anchorPreview && anchorPreview.getPreviewElement) ? anchorPreview.getPreviewElement() : null,
hadFocus = this.base.getFocusedElement(),
toFocus;
// For clicks, we need to know if the mousedown that caused the click happened inside the existing focused element.
// If so, we don't want to focus another element
if (hadFocus &&
eventObj.type === 'click' &&
this.lastMousedownTarget &&
(Util.isDescendant(hadFocus, this.lastMousedownTarget, true) ||
Util.isDescendant(toolbarEl, this.lastMousedownTarget, true) ||
Util.isDescendant(previewEl, this.lastMousedownTarget, true))) {
toFocus = hadFocus;
}
if (!toFocus) {
this.base.elements.some(function (element) {
// If the target is part of an editor element, this is the element getting focus
if (!toFocus && (Util.isDescendant(element, target, true))) {
toFocus = element;
}
// bail if we found an element that's getting focus
return !!toFocus;
}, this);
}
// Check if the target is external (not part of the editor, toolbar, or anchorpreview)
var externalEvent = !Util.isDescendant(hadFocus, target, true) &&
!Util.isDescendant(toolbarEl, target, true) &&
!Util.isDescendant(previewEl, target, true);
if (toFocus !== hadFocus) {
// If element has focus, and focus is going outside of editor
// Don't blur focused element if clicking on editor, toolbar, or anchorpreview
if (hadFocus && externalEvent) {
// Trigger blur on the editable that has lost focus
hadFocus.removeAttribute('data-medium-focused');
this.triggerCustomEvent('blur', eventObj, hadFocus);
}
// If focus is going into an editor element
if (toFocus) {
// Trigger focus on the editable that now has focus
toFocus.setAttribute('data-medium-focused', true);
this.triggerCustomEvent('focus', eventObj, toFocus);
}
}
if (externalEvent) {
this.triggerCustomEvent('externalInteraction', eventObj);
}
},
updateInput: function (target, eventObj) {
// An event triggered which signifies that the user may have changed someting
// Look in our cache of input for the contenteditables to see if something changed
var index = target.getAttribute('medium-editor-index');
if (target.innerHTML !== this.contentCache[index]) {
// The content has changed since the last time we checked, fire the event
this.triggerCustomEvent('editableInput', eventObj, target);
}
this.contentCache[index] = target.innerHTML;
},
handleDocumentSelectionChange: function (event) {
// When selectionchange fires, target and current target are set
// to document, since this is where the event is handled
// However, currentTarget will have an 'activeElement' property
// which will point to whatever element has focus.
if (event.currentTarget && event.currentTarget.activeElement) {
var activeElement = event.currentTarget.activeElement,
currentTarget;
// We can look at the 'activeElement' to determine if the selectionchange has
// happened within a contenteditable owned by this instance of MediumEditor
this.base.elements.some(function (element) {
if (Util.isDescendant(element, activeElement, true)) {
currentTarget = element;
return true;
}
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 (Util.isKey(event, Util.keyCode.ENTER)) {
return this.triggerCustomEvent('editableKeydownEnter', event, event.currentTarget);
}
if (Util.isKey(event, Util.keyCode.TAB)) {
return this.triggerCustomEvent('editableKeydownTab', event, event.currentTarget);
}
if (Util.isKey(event, [Util.keyCode.DELETE, Util.keyCode.BACKSPACE])) {
return this.triggerCustomEvent('editableKeydownDelete', event, event.currentTarget);
}
}
};
}());
/* istanbul ignore next */
var DefaultButton;
/* istanbul ignore next */
(function () {
'use strict';
DefaultButton = function (options, instance) {
Util.deprecated('MediumEditor.statics.DefaultButton', 'MediumEditor.extensions.button', 'v5.0.0');
this.options = options;
this.name = options.name;
this.init(instance);
};
DefaultButton.prototype = {
init: function (instance) {
this.base = instance;
this.button = this.createButton();
this.base.on(this.button, 'click', this.handleClick.bind(this));
if (this.options.key) {
this.base.subscribe('editableKeydown', this.handleKeydown.bind(this));
}
},
getButton: function () {
return this.button;
},
getAction: function () {
return (typeof this.options.action === 'function') ? this.options.action(this.base.options) : this.options.action;
},
getAria: function () {
return (typeof this.options.aria === 'function') ? this.options.aria(this.base.options) : this.options.aria;
},
getTagNames: function () {
return (typeof this.options.tagNames === 'function') ? this.options.tagNames(this.base.options) : this.options.tagNames;
},
createButton: function () {
var button = this.base.options.ownerDocument.createElement('button'),
content = this.options.contentDefault,
ariaLabel = this.getAria();
button.classList.add('medium-editor-action');
button.classList.add('medium-editor-action-' + this.name);
button.setAttribute('data-action', this.getAction());
if (ariaLabel) {
button.setAttribute('title', ariaLabel);
button.setAttribute('aria-label', ariaLabel);
}
if (this.base.options.buttonLabels) {
if (this.base.options.buttonLabels === 'fontawesome' && this.options.contentFA) {
content = this.options.contentFA;
} else if (typeof this.base.options.buttonLabels === 'object' && this.base.options.buttonLabels[this.name]) {
content = this.base.options.buttonLabels[this.options.name];
}
}
button.innerHTML = content;
return button;
},
handleKeydown: function (evt) {
var key = String.fromCharCode(evt.which || evt.keyCode).toLowerCase(),
action;
if (this.options.key === key && Util.isMetaCtrlKey(evt)) {
evt.preventDefault();
evt.stopPropagation();
action = this.getAction();
if (action) {
this.base.execAction(action);
}
}
},
handleClick: function (evt) {
evt.preventDefault();
evt.stopPropagation();
var action = this.getAction();
if (action) {
this.base.execAction(action);
}
},
isActive: function () {
return this.button.classList.contains(this.base.options.activeButtonClass);
},
setInactive: function () {
this.button.classList.remove(this.base.options.activeButtonClass);
delete this.knownState;
},
setActive: function () {
this.button.classList.add(this.base.options.activeButtonClass);
delete this.knownState;
},
queryCommandState: function () {
var queryState = null;
if (this.options.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 && node.tagName) {
isMatch = tagNames.indexOf(node.tagName.toLowerCase()) !== -1;
}
if (!isMatch && this.options.style) {
styleVals = this.options.style.value.split('|');
computedStyle = this.base.options.contentWindow.getComputedStyle(node, null).getPropertyValue(this.options.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.options.style.prop !== 'text-decoration') {
this.knownState = isMatch;
}
}
}, this);
}
return isMatch;
}
};
}());
/* istanbul ignore next */
var AnchorExtension;
/* istanbul ignore next */
(function () {
'use strict';
function AnchorDerived() {
Util.deprecated('MediumEditor.statics.AnchorExtension', 'MediumEditor.extensions.anchor', 'v5.0.0');
this.parent = true;
this.options = {
name: 'anchor',
action: 'createLink',
aria: 'link',
tagNames: ['a'],
contentDefault: '# ',
contentFA: ' ',
key: 'k'
};
this.name = 'anchor';
this.hasForm = true;
}
AnchorDerived.prototype = {
// Button and Extension handling
// labels for the anchor-edit form buttons
formSaveLabel: '✓',
formCloseLabel: '×',
// Called when the button the toolbar is clicked
// Overrides DefaultButton.handleClick
handleClick: function (evt) {
evt.preventDefault();
evt.stopPropagation();
var selectedParentElement = Selection.getSelectedParentElement(Selection.getSelectionRange(this.base.options.ownerDocument));
if (selectedParentElement.tagName &&
selectedParentElement.tagName.toLowerCase() === 'a') {
return this.base.execAction('unlink');
}
if (!this.isDisplayed()) {
this.showForm();
}
return false;
},
// Called when user hits the defined shortcut (CTRL / COMMAND + K)
// Overrides DefaultButton.handleKeydown
handleKeydown: function (evt) {
var key = String.fromCharCode(evt.which || evt.keyCode).toLowerCase();
if (this.options.key === key && Util.isMetaCtrlKey(evt)) {
evt.preventDefault();
evt.stopPropagation();
this.handleClick(evt);
}
},
// 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 = [
' '
];
template.push(
'',
this.base.options.buttonLabels === 'fontawesome' ? ' ' : this.formSaveLabel,
' '
);
template.push('',
this.base.options.buttonLabels === 'fontawesome' ? ' ' : this.formCloseLabel,
' ');
// both of these options are slightly moot with the ability to
// override the various form buildup/serialize functions.
if (this.base.options.anchorTarget) {
// fixme: ideally, this options.anchorInputCheckboxLabel would be a formLabel too,
// figure out how to deprecate? also consider `fa-` icon default implcations.
template.push(
' ',
'',
this.base.options.anchorInputCheckboxLabel,
' '
);
}
if (this.base.options.anchorButton) {
// fixme: expose this `Button` text as a formLabel property, too
// and provide similar access to a `fa-` icon default.
template.push(
' ',
'Button '
);
}
return template.join('');
},
// Used by medium-editor when the default toolbar is to be displayed
isDisplayed: function () {
return this.getForm().style.display === 'block';
},
hideForm: function () {
this.getForm().style.display = 'none';
this.getInput().value = '';
},
showForm: function (linkValue) {
var input = this.getInput();
this.base.saveSelection();
this.base.hideToolbarDefaultActions();
this.getForm().style.display = 'block';
this.base.setToolbarPosition();
input.value = linkValue || '';
input.focus();
},
// Called by core when tearing down medium-editor (deactivate)
deactivate: 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.getForm().querySelector('.medium-editor-toolbar-anchor-target'),
buttonCheckbox = this.getForm().querySelector('.medium-editor-toolbar-anchor-button'),
opts = {
url: this.getInput().value
};
if (this.base.options.checkLinkFormat) {
opts.url = this.checkLinkFormat(opts.url);
}
if (targetCheckbox && targetCheckbox.checked) {
opts.target = '_blank';
} else {
opts.target = '_self';
}
if (buttonCheckbox && buttonCheckbox.checked) {
opts.buttonClass = this.base.options.anchorButtonClass;
}
return opts;
},
doFormSave: function () {
var opts = this.getFormOpts();
this.completeFormSave(opts);
},
completeFormSave: function (opts) {
this.base.restoreSelection();
this.base.createLink(opts);
this.base.checkSelection();
},
checkLinkFormat: function (value) {
var re = /^(https?|ftps?|rtmpt?):\/\/|mailto:/;
return (re.test(value) ? '' : 'http://') + value;
},
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.base.on(form, 'click', this.handleFormClick.bind(this));
// Handle typing in the textbox
this.base.on(input, 'keyup', this.handleTextboxKeyup.bind(this));
// Handle close button clicks
this.base.on(close, 'click', this.handleCloseClick.bind(this));
// Handle save button clicks (capture)
this.base.on(save, 'click', this.handleSaveClick.bind(this), true);
},
createForm: function () {
var doc = this.base.options.ownerDocument,
form = doc.createElement('div');
// Anchor Form (div)
form.className = 'medium-editor-toolbar-form';
form.id = 'medium-editor-toolbar-form-anchor-' + this.base.id;
form.innerHTML = this.getTemplate();
this.attachFormEvents(form);
return form;
},
getInput: function () {
return this.getForm().querySelector('input.medium-editor-toolbar-input');
},
handleTextboxKeyup: function (event) {
// For ENTER -> create the anchor
if (event.keyCode === Util.keyCode.ENTER) {
event.preventDefault();
this.doFormSave();
return;
}
// For ESCAPE -> close the form
if (event.keyCode === 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();
}
};
AnchorExtension = Util.derives(DefaultButton, AnchorDerived);
}());
/* istanbul ignore next */
var AnchorPreviewDeprecated;
/* istanbul ignore next */
(function () {
'use strict';
AnchorPreviewDeprecated = function () {
Util.deprecated('MediumEditor.statics.AnchorPreview', 'MediumEditor.extensions.anchorPreview', 'v5.0.0');
this.parent = true;
this.name = 'anchor-preview';
};
AnchorPreviewDeprecated.prototype = {
// the default selector to locate where to
// put the activeAnchor value in the preview
previewValueSelector: 'a',
init: function () {
this.anchorPreview = this.createPreview();
this.base.options.elementsContainer.appendChild(this.anchorPreview);
this.attachToEditables();
},
getPreviewElement: function () {
return this.anchorPreview;
},
createPreview: function () {
var el = this.base.options.ownerDocument.createElement('div');
el.id = 'medium-editor-anchor-preview-' + this.base.id;
el.className = 'medium-editor-anchor-preview';
el.innerHTML = this.getTemplate();
this.base.on(el, 'click', this.handleClick.bind(this));
return el;
},
getTemplate: function () {
return '';
},
deactivate: function () {
if (this.anchorPreview) {
if (this.anchorPreview.parentNode) {
this.anchorPreview.parentNode.removeChild(this.anchorPreview);
}
delete this.anchorPreview;
}
},
hidePreview: function () {
this.anchorPreview.classList.remove('medium-editor-anchor-preview-active');
this.activeAnchor = null;
},
showPreview: function (anchorEl) {
if (this.anchorPreview.classList.contains('medium-editor-anchor-preview-active') ||
anchorEl.getAttribute('data-disable-preview')) {
return true;
}
if (this.previewValueSelector) {
this.anchorPreview.querySelector(this.previewValueSelector).textContent = anchorEl.attributes.href.value;
this.anchorPreview.querySelector(this.previewValueSelector).href = anchorEl.attributes.href.value;
}
this.anchorPreview.classList.add('medium-toolbar-arrow-over');
this.anchorPreview.classList.remove('medium-toolbar-arrow-under');
if (!this.anchorPreview.classList.contains('medium-editor-anchor-preview-active')) {
this.anchorPreview.classList.add('medium-editor-anchor-preview-active');
}
this.activeAnchor = anchorEl;
this.positionPreview();
this.attachPreviewHandlers();
return this;
},
positionPreview: function () {
var buttonHeight = this.anchorPreview.offsetHeight,
boundary = this.activeAnchor.getBoundingClientRect(),
middleBoundary = (boundary.left + boundary.right) / 2,
halfOffsetWidth,
defaultLeft;
halfOffsetWidth = this.anchorPreview.offsetWidth / 2;
defaultLeft = this.base.options.diffLeft - halfOffsetWidth;
this.anchorPreview.style.top = Math.round(buttonHeight + boundary.bottom - this.base.options.diffTop + this.base.options.contentWindow.pageYOffset - this.anchorPreview.offsetHeight) + 'px';
if (middleBoundary < halfOffsetWidth) {
this.anchorPreview.style.left = defaultLeft + halfOffsetWidth + 'px';
} else if ((this.base.options.contentWindow.innerWidth - middleBoundary) < halfOffsetWidth) {
this.anchorPreview.style.left = this.base.options.contentWindow.innerWidth + defaultLeft - halfOffsetWidth + 'px';
} else {
this.anchorPreview.style.left = defaultLeft + middleBoundary + 'px';
}
},
attachToEditables: function () {
this.base.subscribe('editableMouseover', this.handleEditableMouseover.bind(this));
},
handleClick: function (event) {
var anchorExtension = this.base.getExtensionByName('anchor'),
activeAnchor = this.activeAnchor;
if (anchorExtension && activeAnchor) {
event.preventDefault();
this.base.selectElement(this.activeAnchor);
// Using setTimeout + options.delay because:
// We may actually be displaying the anchor form, which should be controlled by options.delay
this.base.delay(function () {
if (activeAnchor) {
anchorExtension.showForm(activeAnchor.attributes.href.value);
activeAnchor = null;
}
}.bind(this));
}
this.hidePreview();
},
handleAnchorMouseout: function () {
this.anchorToPreview = null;
this.base.off(this.activeAnchor, 'mouseout', this.instanceHandleAnchorMouseout);
this.instanceHandleAnchorMouseout = null;
},
handleEditableMouseover: function (event) {
var target = Util.getClosestTag(event.target, 'a');
if (target) {
// Detect empty href attributes
// The browser will make href="" or href="#top"
// into absolute urls when accessed as event.targed.href, so check the html
if (!/href=["']\S+["']/.test(target.outerHTML) || /href=["']#\S+["']/.test(target.outerHTML)) {
return true;
}
// only show when hovering on anchors
if (this.base.toolbar && this.base.toolbar.isDisplayed()) {
// only show when toolbar is not present
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.base.on(this.anchorToPreview, 'mouseout', this.instanceHandleAnchorMouseout);
// Using setTimeout + options.delay because:
// - We're going to show the anchor preview according to the configured delay
// if the mouse has not left the anchor tag in that time
this.base.delay(function () {
if (this.anchorToPreview) {
//this.activeAnchor = 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.base.options.anchorPreviewHideDelay) {
// hide the preview 1/2 second after mouse leaves the link
this.detachPreviewHandlers();
}
},
detachPreviewHandlers: function () {
// cleanup
clearInterval(this.intervalTimer);
if (this.instanceHandlePreviewMouseover) {
this.base.off(this.anchorPreview, 'mouseover', this.instanceHandlePreviewMouseover);
this.base.off(this.anchorPreview, 'mouseout', this.instanceHandlePreviewMouseout);
if (this.activeAnchor) {
this.base.off(this.activeAnchor, 'mouseover', this.instanceHandlePreviewMouseover);
this.base.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.base.on(this.anchorPreview, 'mouseover', this.instanceHandlePreviewMouseover);
this.base.on(this.anchorPreview, 'mouseout', this.instanceHandlePreviewMouseout);
this.base.on(this.activeAnchor, 'mouseover', this.instanceHandlePreviewMouseover);
this.base.on(this.activeAnchor, 'mouseout', this.instanceHandlePreviewMouseout);
}
};
}());
/* istanbul ignore next */
var FontSizeExtension;
/* istanbul ignore next */
(function () {
'use strict';
function FontSizeDerived() {
Util.deprecated('MediumEditor.statics.FontSizeExtension', 'MediumEditor.extensions.fontSize', 'v5.0.0');
this.parent = true;
this.options = {
name: 'fontsize',
action: 'fontSize',
aria: 'increase/decrease font size',
contentDefault: '±', // ±
contentFA: ' '
};
this.name = 'fontsize';
this.hasForm = true;
}
FontSizeDerived.prototype = {
// Button and Extension handling
// Called when the button the toolbar is clicked
// Overrides DefaultButton.handleClick
handleClick: function (evt) {
evt.preventDefault();
evt.stopPropagation();
if (!this.isDisplayed()) {
// Get fontsize of current selection (convert to string since IE returns this as number)
var fontSize = this.base.options.ownerDocument.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.base.hideToolbarDefaultActions();
this.getForm().style.display = 'block';
this.base.setToolbarPosition();
input.value = fontSize || '';
input.focus();
},
// Called by core when tearing down medium-editor (deactivate)
deactivate: 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.base.options.ownerDocument,
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.base.id;
// Handle clicks on the form itself
this.base.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.base.on(input, 'change', this.handleSliderChange.bind(this));
// Add save buton
save.setAttribute('href', '#');
save.className = 'medium-editor-toobar-save';
save.innerHTML = this.base.options.buttonLabels === 'fontawesome' ?
' ' :
'✓';
form.appendChild(save);
// Handle save button clicks (capture)
this.base.on(save, 'click', this.handleSaveClick.bind(this), true);
// Add close button
close.setAttribute('href', '#');
close.className = 'medium-editor-toobar-close';
close.innerHTML = this.base.options.buttonLabels === 'fontawesome' ?
' ' :
'×';
form.appendChild(close);
// Handle close button clicks
this.base.on(close, 'click', this.handleCloseClick.bind(this));
return form;
},
getInput: function () {
return this.getForm().querySelector('input.medium-editor-toolbar-input');
},
clearFontSize: function () {
Selection.getSelectedElements(this.base.options.ownerDocument).forEach(function (el) {
if (el.tagName === 'FONT' && el.hasAttribute('size')) {
el.removeAttribute('size');
}
});
},
handleSliderChange: function () {
var size = this.getInput().value;
if (size === '4') {
this.clearFontSize();
} else {
this.base.execAction('fontSize', { size: 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();
}
};
FontSizeExtension = Util.derives(DefaultButton, FontSizeDerived);
}());
var Button;
(function () {
'use strict';
/*global Util, Extension */
Button = Extension.extend({
init: function () {
this.button = this.createButton();
this.on(this.button, 'click', this.handleClick.bind(this));
if (this.key) {
this.subscribe('editableKeydown', this.handleKeydown.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');
button.classList.add('medium-editor-action');
button.classList.add('medium-editor-action-' + this.name);
button.setAttribute('data-action', this.getAction());
if (ariaLabel) {
button.setAttribute('title', ariaLabel);
button.setAttribute('aria-label', ariaLabel);
}
if (buttonLabels) {
if (buttonLabels === 'fontawesome' && this.contentFA) {
content = this.contentFA;
} else if (typeof buttonLabels === 'object' && buttonLabels[this.name]) {
content = buttonLabels[this.name];
}
}
button.innerHTML = content;
return button;
},
handleKeydown: function (event) {
var action;
if (Util.isKey(event, this.key.charCodeAt(0)) && Util.isMetaCtrlKey(event) && !event.shiftKey) {
event.preventDefault();
event.stopPropagation();
action = this.getAction();
if (action) {
this.execAction(action);
}
}
},
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 && node.tagName) {
isMatch = tagNames.indexOf(node.tagName.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;
}
});
}());
var FormExtension;
(function () {
'use strict';
/* global Button */
var noop = function () {};
/* Base functionality for an extension whcih will display
* a 'form' inside the toolbar
*/
FormExtension = Button.extend({
// default labels for the form buttons
formSaveLabel: '✓',
formCloseLabel: '×',
/* hasForm: [boolean]
*
* Setting this to true will cause getForm() to be called
* when the toolbar is created, so the form can be appended
* inside the toolbar container
*/
hasForm: true,
/* getForm: [function ()]
*
* When hasForm is true, this function must be implemented
* and return a DOM Element which will be appended to
* the toolbar container. The form should start hidden, and
* the extension can choose when to hide/show it
*/
getForm: noop,
/* isDisplayed: [function ()]
*
* This function should return true/false reflecting
* whether the form is currently displayed
*/
isDisplayed: noop,
/* hideForm: [function ()]
*
* This function should hide the form element inside
* the toolbar container
*/
hideForm: noop
});
})();
var AnchorForm;
(function () {
'use strict';
/*global Util, Selection, FormExtension */
AnchorForm = FormExtension.extend({
/* Anchor Form Options */
/* customClassOption: [string] (previously options.anchorButton + options.anchorButtonClass)
* Custom class name the user can optionally have added to their created links (ie 'button').
* If passed as a non-empty string, a checkbox will be displayed allowing the user to choose
* whether to have the class added to the created link or not.
*/
customClassOption: null,
/* customClassOptionText: [string]
* text to be shown in the checkbox when the __customClassOption__ is being used.
*/
customClassOptionText: 'Button',
/* linkValidation: [boolean] (previously options.checkLinkFormat)
* enables/disables check for common URL protocols on anchor links.
*/
linkValidation: false,
/* placeholderText: [string] (previously options.anchorInputPlaceholder)
* text to be shown as placeholder of the anchor input.
*/
placeholderText: 'Paste or type a link',
/* targetCheckbox: [boolean] (previously options.anchorTarget)
* enables/disables displaying a "Open in new window" checkbox, which when checked
* changes the `target` attribute of the created link.
*/
targetCheckbox: false,
/* targetCheckboxText: [string] (previously options.anchorInputCheckboxLabel)
* text to be shown in the checkbox enabled via the __targetCheckbox__ option.
*/
targetCheckboxText: 'Open in new window',
// Options for the Button base class
name: 'anchor',
action: 'createLink',
aria: 'link',
tagNames: ['a'],
contentDefault: '# ',
contentFA: ' ',
key: 'K',
// Called when the button the toolbar is clicked
// Overrides ButtonExtension.handleClick
handleClick: function (event) {
event.preventDefault();
event.stopPropagation();
var range = Selection.getSelectionRange(this.document);
if (range.startContainer.nodeName.toLowerCase() === 'a' ||
range.endContainer.nodeName.toLowerCase() === 'a' ||
Util.getClosestTag(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)
// Overrides Button.handleKeydown
handleKeydown: function (event) {
if (Util.isKey(event, this.key.charCodeAt(0)) && Util.isMetaCtrlKey(event)) {
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 = [
' '
];
template.push(
'',
this.getEditorOption('buttonLabels') === 'fontawesome' ? ' ' : this.formSaveLabel,
' '
);
template.push('',
this.getEditorOption('buttonLabels') === 'fontawesome' ? ' ' : this.formCloseLabel,
' ');
// 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(
' ',
'',
this.targetCheckboxText,
' '
);
}
if (this.customClassOption) {
// fixme: expose this `Button` text as a formLabel property, too
// and provide similar access to a `fa-` icon default.
template.push(
' ',
'',
this.customClassOptionText,
' '
);
}
return template.join('');
},
// Used by medium-editor when the default toolbar is to be displayed
isDisplayed: function () {
return this.getForm().style.display === 'block';
},
hideForm: function () {
this.getForm().style.display = 'none';
this.getInput().value = '';
},
showForm: function (linkValue) {
var input = this.getInput();
this.base.saveSelection();
this.base.hideToolbarDefaultActions();
this.getForm().style.display = 'block';
this.base.setToolbarPosition();
input.value = linkValue || '';
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;
},
// TODO: deprecate
deactivate: function () {
Util.deprecatedMethod.call(this, 'deactivate', 'destroy', arguments, 'v5.0.0');
},
// core methods
getFormOpts: function () {
// no notion of private functions? wanted `_getFormOpts`
var targetCheckbox = this.getForm().querySelector('.medium-editor-toolbar-anchor-target'),
buttonCheckbox = this.getForm().querySelector('.medium-editor-toolbar-anchor-button'),
opts = {
url: this.getInput().value
};
if (this.linkValidation) {
opts.url = this.checkLinkFormat(opts.url);
}
opts.target = '_self';
if (targetCheckbox && targetCheckbox.checked) {
opts.target = '_blank';
}
if (buttonCheckbox && buttonCheckbox.checked) {
opts.buttonClass = this.customClassOption;
}
return opts;
},
doFormSave: function () {
var opts = this.getFormOpts();
this.completeFormSave(opts);
},
completeFormSave: function (opts) {
this.base.restoreSelection();
this.execAction(this.action, opts);
this.base.checkSelection();
},
checkLinkFormat: function (value) {
var re = /^(https?|ftps?|rtmpt?):\/\/|mailto:/;
return (re.test(value) ? '' : 'http://') + value;
},
doFormCancel: function () {
this.base.restoreSelection();
this.base.checkSelection();
},
// form creation and event handling
attachFormEvents: function (form) {
var close = form.querySelector('.medium-editor-toolbar-close'),
save = form.querySelector('.medium-editor-toolbar-save'),
input = form.querySelector('.medium-editor-toolbar-input');
// Handle clicks on the form itself
this.on(form, 'click', this.handleFormClick.bind(this));
// Handle typing in the textbox
this.on(input, 'keyup', this.handleTextboxKeyup.bind(this));
// Handle close button clicks
this.on(close, 'click', this.handleCloseClick.bind(this));
// Handle save button clicks (capture)
this.on(save, 'click', this.handleSaveClick.bind(this), true);
},
createForm: function () {
var doc = this.document,
form = doc.createElement('div');
// Anchor Form (div)
form.className = 'medium-editor-toolbar-form';
form.id = 'medium-editor-toolbar-form-anchor-' + this.getEditorId();
form.innerHTML = this.getTemplate();
this.attachFormEvents(form);
return form;
},
getInput: function () {
return this.getForm().querySelector('input.medium-editor-toolbar-input');
},
handleTextboxKeyup: function (event) {
// For ENTER -> create the anchor
if (event.keyCode === Util.keyCode.ENTER) {
event.preventDefault();
this.doFormSave();
return;
}
// For ESCAPE -> close the form
if (event.keyCode === 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();
}
});
}());
var AnchorPreview;
(function () {
'use strict';
/*global Util, Extension */
AnchorPreview = 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',
/* ----- internal options needed from base ----- */
diffLeft: 0, // deprecated (should use .getEditorOption() instead)
diffTop: -10, // deprecated (should use .getEditorOption() instead)
elementsContainer: false, // deprecated (should use .getEditorOption() instead)
init: function () {
this.anchorPreview = this.createPreview();
if (!this.elementsContainer) {
this.elementsContainer = this.document.body;
}
this.elementsContainer.appendChild(this.anchorPreview);
this.attachToEditables();
},
getPreviewElement: function () {
return this.anchorPreview;
},
createPreview: function () {
var el = this.document.createElement('div');
el.id = 'medium-editor-anchor-preview-' + this.getEditorId();
el.className = 'medium-editor-anchor-preview';
el.innerHTML = this.getTemplate();
this.on(el, 'click', this.handleClick.bind(this));
return el;
},
getTemplate: function () {
return '';
},
destroy: function () {
if (this.anchorPreview) {
if (this.anchorPreview.parentNode) {
this.anchorPreview.parentNode.removeChild(this.anchorPreview);
}
delete this.anchorPreview;
}
},
// TODO: deprecate
deactivate: function () {
Util.deprecatedMethod.call(this, 'deactivate', 'destroy', arguments, 'v5.0.0');
},
hidePreview: function () {
this.anchorPreview.classList.remove('medium-editor-anchor-preview-active');
this.activeAnchor = null;
},
showPreview: function (anchorEl) {
if (this.anchorPreview.classList.contains('medium-editor-anchor-preview-active') ||
anchorEl.getAttribute('data-disable-preview')) {
return true;
}
if (this.previewValueSelector) {
this.anchorPreview.querySelector(this.previewValueSelector).textContent = anchorEl.attributes.href.value;
this.anchorPreview.querySelector(this.previewValueSelector).href = anchorEl.attributes.href.value;
}
this.anchorPreview.classList.add('medium-toolbar-arrow-over');
this.anchorPreview.classList.remove('medium-toolbar-arrow-under');
if (!this.anchorPreview.classList.contains('medium-editor-anchor-preview-active')) {
this.anchorPreview.classList.add('medium-editor-anchor-preview-active');
}
this.activeAnchor = anchorEl;
this.positionPreview();
this.attachPreviewHandlers();
return this;
},
positionPreview: function () {
var buttonHeight = this.anchorPreview.offsetHeight,
boundary = this.activeAnchor.getBoundingClientRect(),
middleBoundary = (boundary.left + boundary.right) / 2,
halfOffsetWidth,
defaultLeft;
halfOffsetWidth = this.anchorPreview.offsetWidth / 2;
defaultLeft = this.diffLeft - halfOffsetWidth;
this.anchorPreview.style.top = Math.round(buttonHeight + boundary.bottom - this.diffTop + this.window.pageYOffset - this.anchorPreview.offsetHeight) + 'px';
if (middleBoundary < halfOffsetWidth) {
this.anchorPreview.style.left = defaultLeft + halfOffsetWidth + 'px';
} else if ((this.window.innerWidth - middleBoundary) < halfOffsetWidth) {
this.anchorPreview.style.left = this.window.innerWidth + defaultLeft - halfOffsetWidth + 'px';
} else {
this.anchorPreview.style.left = defaultLeft + middleBoundary + 'px';
}
},
attachToEditables: function () {
this.subscribe('editableMouseover', this.handleEditableMouseover.bind(this));
},
handleClick: function (event) {
var anchorExtension = this.base.getExtensionByName('anchor'),
activeAnchor = this.activeAnchor;
if (anchorExtension && activeAnchor) {
event.preventDefault();
this.base.selectElement(this.activeAnchor);
// Using setTimeout + delay because:
// We may actually be displaying the anchor form, which should be controlled by delay
this.base.delay(function () {
if (activeAnchor) {
anchorExtension.showForm(activeAnchor.attributes.href.value);
activeAnchor = null;
}
}.bind(this));
}
this.hidePreview();
},
handleAnchorMouseout: function () {
this.anchorToPreview = null;
this.off(this.activeAnchor, 'mouseout', this.instanceHandleAnchorMouseout);
this.instanceHandleAnchorMouseout = null;
},
handleEditableMouseover: function (event) {
var target = Util.getClosestTag(event.target, 'a');
if (false === target) {
return;
}
// Detect empty href attributes
// The browser will make href="" or href="#top"
// into absolute urls when accessed as event.targed.href, so check the html
if (!/href=["']\S+["']/.test(target.outerHTML) || /href=["']#\S+["']/.test(target.outerHTML)) {
return true;
}
// only show when hovering on anchors
if (this.base.toolbar && this.base.toolbar.isDisplayed()) {
// only show when toolbar is not present
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.activeAnchor = 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);
}
});
}());
var AutoLink,
WHITESPACE_CHARS,
KNOWN_TLDS_FRAGMENT,
LINK_REGEXP_TEXT;
WHITESPACE_CHARS = [' ', '\t', '\n', '\r', '\u00A0', '\u2000', '\u2001', '\u2002', '\u2003',
'\u2028', '\u2029'];
KNOWN_TLDS_FRAGMENT = 'com|net|org|edu|gov|mil|aero|asia|biz|cat|coop|info|int|jobs|mobi|museum|name|post|pro|tel|travel|' +
'xxx|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|' +
'bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|eu|fi|fj|' +
'fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|' +
'is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|' +
'mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|' +
'pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|ja|sk|sl|sm|sn|so|sr|ss|st|su|sv|sx|sy|sz|tc|td|tf|tg|th|' +
'tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|yu|za|zm|zw';
LINK_REGEXP_TEXT =
'(' +
// Version of Gruber URL Regexp optimized for JS: http://stackoverflow.com/a/17733640
'((?:(https?://|ftps?://|nntp://)|www\\d{0,3}[.]|[a-z0-9.\\-]+[.](' + KNOWN_TLDS_FRAGMENT + ')\\\/)\\S+(?:[^\\s`!\\[\\]{};:\'\".,?\u00AB\u00BB\u201C\u201D\u2018\u2019]))' +
// Addition to above Regexp to support bare domains/one level subdomains with common non-i18n TLDs and without www prefix:
')|(([a-z0-9\\-]+\\.)?[a-z0-9\\-]+\\.(' + KNOWN_TLDS_FRAGMENT + '))';
(function () {
'use strict';
var KNOWN_TLDS_REGEXP = new RegExp('^(' + KNOWN_TLDS_FRAGMENT + ')$', 'i');
function nodeIsNotInsideAnchorTag(node) {
return !Util.getClosestTag(node, 'a');
}
AutoLink = Extension.extend({
init: function () {
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);
},
destroy: function () {
// Turn AutoUrlDetect back on
if (this.document.queryCommandSupported('AutoUrlDetect')) {
this.document.execCommand('AutoUrlDetect', false, true);
}
},
onBlur: function (blurEvent, editable) {
this.performLinking(editable);
},
onKeypress: function (keyPressEvent) {
if (this.disableEventHandling) {
return;
}
if (Util.isKey(keyPressEvent, [Util.keyCode.SPACE, Util.keyCode.ENTER])) {
clearTimeout(this.performLinkingTimeout);
// Saving/restoring the selection in the middle of a keypress doesn't work well...
this.performLinkingTimeout = setTimeout(function () {
try {
var sel = this.base.exportSelection();
if (this.performLinking(keyPressEvent.target)) {
// pass true for favorLaterSelectionAnchor - this is needed for links at the end of a
// paragraph in MS IE, or MS IE causes the link to be deleted right after adding it.
this.base.importSelection(sel, true);
}
} catch (e) {
if (window.console) {
window.console.error('Failed to perform linking', e);
}
this.disableEventHandling = true;
}
}.bind(this), 0);
}
},
performLinking: function (contenteditable) {
// Perform linking on a paragraph level basis as otherwise the detection can wrongly find the end
// of one paragraph and the beginning of another paragraph to constitute a link, such as a paragraph ending
// "link." and the next paragraph beginning with "my" is interpreted into "link.my" and the code tries to create
// a link across paragraphs - which doesn't work and is terrible.
// (Medium deletes the spaces/returns between P tags so the textContent ends up without paragraph spacing)
var paragraphs = contenteditable.querySelectorAll('p'),
documentModified = false;
if (paragraphs.length === 0) {
paragraphs = [contenteditable];
}
for (var i = 0; i < paragraphs.length; i++) {
documentModified = this.removeObsoleteAutoLinkSpans(paragraphs[i]) || documentModified;
documentModified = this.performLinkingWithinElement(paragraphs[i]) || documentModified;
}
return documentModified;
},
splitStartNodeIfNeeded: function (currentNode, matchStartIndex, currentTextIndex) {
if (matchStartIndex !== currentTextIndex) {
return currentNode.splitText(matchStartIndex - currentTextIndex);
}
return null;
},
splitEndNodeIfNeeded: function (currentNode, newNode, matchEndIndex, currentTextIndex) {
var textIndexOfEndOfFarthestNode,
endSplitPoint;
textIndexOfEndOfFarthestNode = currentTextIndex + (newNode || currentNode).nodeValue.length +
(newNode ? currentNode.nodeValue.length : 0) -
1;
endSplitPoint = (newNode || currentNode).nodeValue.length -
(textIndexOfEndOfFarthestNode + 1 - matchEndIndex);
if (textIndexOfEndOfFarthestNode >= matchEndIndex &&
currentTextIndex !== textIndexOfEndOfFarthestNode &&
endSplitPoint !== 0) {
(newNode || currentNode).splitText(endSplitPoint);
}
},
removeObsoleteAutoLinkSpans: function (element) {
var spans = element.querySelectorAll('span[data-auto-link="true"]'),
documentModified = false;
for (var i = 0; i < spans.length; i++) {
var textContent = spans[i].textContent;
if (textContent.indexOf('://') === -1) {
textContent = Util.ensureUrlHasProtocol(textContent);
}
if (spans[i].getAttribute('data-href') !== textContent && nodeIsNotInsideAnchorTag(spans[i])) {
documentModified = true;
var trimmedTextContent = textContent.replace(/\s+$/, '');
if (spans[i].getAttribute('data-href') === trimmedTextContent) {
var charactersTrimmed = textContent.length - trimmedTextContent.length,
subtree = Util.splitOffDOMTree(spans[i], this.splitTextBeforeEnd(spans[i], charactersTrimmed));
spans[i].parentNode.insertBefore(subtree, spans[i].nextSibling);
} else {
// Some editing has happened to the span, so just remove it entirely. The user can put it back
// around just the href content if they need to prevent it from linking
Util.unwrap(spans[i], this.document);
}
}
}
return 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++) {
linkCreated = this.createLink(this.findOrCreateMatchingTextNodes(element, matches[matchIndex]),
matches[matchIndex].href) || linkCreated;
}
return linkCreated;
},
findLinkableText: function (contenteditable) {
var linkRegExp = new RegExp(LINK_REGEXP_TEXT, 'gi'),
textContent = contenteditable.textContent,
match = null,
matches = [];
while ((match = linkRegExp.exec(textContent)) !== null) {
var matchOk = true,
matchEnd = match.index + match[0].length;
// If the regexp detected something as a link that has text immediately preceding/following it, bail out.
matchOk = (match.index === 0 || WHITESPACE_CHARS.indexOf(textContent[match.index - 1]) !== -1) &&
(matchEnd === textContent.length || WHITESPACE_CHARS.indexOf(textContent[matchEnd]) !== -1);
// If the regexp detected a bare domain that doesn't use one of our expected TLDs, bail out.
matchOk = matchOk && (match[0].indexOf('/') !== -1 ||
KNOWN_TLDS_REGEXP.test(match[0].split('.').pop().split('?').shift()));
if (matchOk) {
matches.push({
href: match[0],
start: match.index,
end: matchEnd
});
}
}
return matches;
},
findOrCreateMatchingTextNodes: function (element, match) {
var treeWalker = this.document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false),
matchedNodes = [],
currentTextIndex = 0,
startReached = false,
currentNode = null,
newNode = null;
while ((currentNode = treeWalker.nextNode()) !== null) {
if (!startReached && match.start < (currentTextIndex + currentNode.nodeValue.length)) {
startReached = true;
newNode = this.splitStartNodeIfNeeded(currentNode, match.start, currentTextIndex);
}
if (startReached) {
this.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;
}
return matchedNodes;
},
createLink: function (textNodes, href) {
var shouldNotLink = false;
for (var i = 0; i < textNodes.length && shouldNotLink === false; i++) {
// Do not link if the text node is either inside an anchor or inside span[data-auto-link]
shouldNotLink = !!Util.traverseUp(textNodes[i], function (node) {
return node.nodeName.toLowerCase() === 'a' ||
(node.getAttribute && node.getAttribute('data-auto-link') === 'true');
});
}
if (shouldNotLink) {
return false;
}
var anchor = this.document.createElement('a'),
span = this.document.createElement('span'),
hrefWithProtocol = Util.ensureUrlHasProtocol(href);
Util.moveTextRangeIntoElement(textNodes[0], textNodes[textNodes.length - 1], span);
span.setAttribute('data-auto-link', 'true');
span.setAttribute('data-href', hrefWithProtocol);
anchor.setAttribute('href', hrefWithProtocol);
span.parentNode.insertBefore(anchor, span);
anchor.appendChild(span);
return true;
}
});
}());
var ImageDragging;
(function () {
'use strict';
ImageDragging = Extension.extend({
init: function () {
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());
Util.insertHTMLCommand(this.document, ' ');
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);
}
});
}());
var FontSizeForm;
(function () {
'use strict';
/*global FormExtension, Selection, Util */
FontSizeForm = FormExtension.extend({
name: 'fontsize',
action: 'fontSize',
aria: 'increase/decrease font size',
contentDefault: '±', // ±
contentFA: ' ',
// Called when the button the toolbar is clicked
// Overrides ButtonExtension.handleClick
handleClick: function (evt) {
evt.preventDefault();
evt.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.base.hideToolbarDefaultActions();
this.getForm().style.display = 'block';
this.base.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;
},
// TODO: deprecate
deactivate: function () {
Util.deprecatedMethod.call(this, 'deactivate', 'destroy', arguments, 'v5.0.0');
},
// 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' ?
' ' :
'✓';
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' ?
' ' :
'×';
form.appendChild(close);
// Handle close button clicks
this.on(close, 'click', this.handleCloseClick.bind(this));
return form;
},
getInput: function () {
return this.getForm().querySelector('input.medium-editor-toolbar-input');
},
clearFontSize: function () {
Selection.getSelectedElements(this.document).forEach(function (el) {
if (el.tagName === 'FONT' && el.hasAttribute('size')) {
el.removeAttribute('size');
}
});
},
handleSliderChange: function () {
var size = this.getInput().value;
if (size === '4') {
this.clearFontSize();
} else {
this.execAction('fontSize', { size: 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();
}
});
}());
var PasteHandler;
(function () {
'use strict';
/*jslint regexp: true*/
/*
jslint does not allow character negation, because the negation
will not match any unicode characters. In the regexes in this
block, negation is used specifically to match the end of an html
tag, and in fact unicode characters *should* be allowed.
*/
function createReplacements() {
return [
// replace two bogus tags that begin pastes from google docs
[new RegExp(/<[^>]*docs-internal-guid[^>]*>/gi), ''],
[new RegExp(/<\/b>( ]*>)?$/gi), ''],
// un-html spaces and newlines inserted by OS X
[new RegExp(/\s+<\/span>/g), ' '],
[new RegExp(/ /g), ' '],
// replace google docs italics+bold with a span to be replaced once the html is inserted
[new RegExp(/]*(font-style:italic;font-weight:bold|font-weight:bold;font-style:italic)[^>]*>/gi), ''],
// replace google docs italics with a span to be replaced once the html is inserted
[new RegExp(/]*font-style:italic[^>]*>/gi), ''],
//[replace google docs bolds with a span to be replaced once the html is inserted
[new RegExp(/]*font-weight:bold[^>]*>/gi), ''],
// replace manually entered b/i/a tags with real ones
[new RegExp(/<(\/?)(i|b|a)>/gi), '<$1$2>'],
// replace manually a tags with real ones, converting smart-quotes from google docs
[new RegExp(/<a(?:(?!href).)+href=(?:"|”|“|"|“|”)(((?!"|”|“|"|“|”).)*)(?:"|”|“|"|“|”)(?:(?!>).)*>/gi), ''],
// 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), ''],
[new RegExp(/\n+
[new RegExp(/<\/?o:[a-z]*>/gi), '']
];
}
/*jslint regexp: false*/
PasteHandler = Extension.extend({
/* Paste Options */
/* forcePlainText: [boolean]
* Forces pasting as plain text.
*/
forcePlainText: true,
/* cleanPastedHTML: [boolean]
* cleans pasted content from different sources, like google docs etc.
*/
cleanPastedHTML: false,
/* cleanReplacements: [Array]
* custom pairs (2 element arrays) of RegExp and replacement text to use during paste when
* __forcePlainText__ or __cleanPastedHTML__ are `true` OR when calling `cleanPaste(text)` helper method.
*/
cleanReplacements: [],
/* cleanAttrs:: [Array]
* list of element attributes to remove during paste when __cleanPastedHTML__ is `true` or when
* calling `cleanPaste(text)` or `pasteHTML(html, options)` helper methods.
*/
cleanAttrs: ['class', 'style', 'dir'],
/* cleanTags: [Array]
* list of element tag names to remove during paste when __cleanPastedHTML__ is `true` or when
* calling `cleanPaste(text)` or `pasteHTML(html, options)` helper methods.
*/
cleanTags: ['meta'],
/* ----- internal options needed from base ----- */
targetBlank: false, // deprecated (should use .getEditorOption() instead)
disableReturn: false, // deprecated (should use .getEditorOption() instead)
init: function () {
if (this.forcePlainText || this.cleanPastedHTML) {
this.subscribe('editablePaste', this.handlePaste.bind(this));
}
},
handlePaste: function (event, element) {
var paragraphs,
html = '',
p,
dataFormatHTML = 'text/html',
dataFormatPlain = 'text/plain',
pastedHTML,
pastedPlain;
if (this.window.clipboardData && event.clipboardData === undefined) {
event.clipboardData = this.window.clipboardData;
// If window.clipboardData exists, but event.clipboardData doesn't exist,
// we're probably in IE. IE only has two possibilities for clipboard
// data format: 'Text' and 'URL'.
//
// Of the two, we want 'Text':
dataFormatHTML = 'Text';
dataFormatPlain = 'Text';
}
if (event.clipboardData &&
event.clipboardData.getData &&
!event.defaultPrevented) {
event.preventDefault();
pastedHTML = event.clipboardData.getData(dataFormatHTML);
pastedPlain = event.clipboardData.getData(dataFormatPlain);
if (this.cleanPastedHTML && pastedHTML) {
return this.cleanPaste(pastedHTML);
}
if (!(this.disableReturn || element.getAttribute('data-disable-return'))) {
paragraphs = pastedPlain.split(/[\r\n]+/g);
// If there are no \r\n in data, don't wrap in
if (paragraphs.length > 1) {
for (p = 0; p < paragraphs.length; p += 1) {
if (paragraphs[p] !== '') {
html += '
' + Util.htmlEntities(paragraphs[p]) + '
';
}
}
} else {
html = Util.htmlEntities(paragraphs[0]);
}
} else {
html = Util.htmlEntities(pastedPlain);
}
Util.insertHTMLCommand(this.document, html);
}
},
cleanPaste: function (text) {
var i, elList,
multiline = / ');
this.pasteHTML('
' + elList.join('
') + '
');
},
pasteHTML: function (html, options) {
options = Util.defaults({}, options, {
cleanAttrs: this.cleanAttrs,
cleanTags: this.cleanTags
});
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.tagName.toLowerCase() && this.targetBlank) {
Util.setTargetBlank(workEl);
}
Util.cleanupAttrs(workEl, options.cleanAttrs);
Util.cleanupTags(workEl, options.cleanTags);
}
// block element cleanup
elList = fragmentBody.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;
}
}
Util.insertHTMLCommand(this.document, fragmentBody.innerHTML.replace(/ /g, ' '));
},
isCommonBlock: function (el) {
return (el && (el.tagName.toLowerCase() === 'p' || el.tagName.toLowerCase() === 'div'));
},
filterCommonBlocks: function (el) {
if (/^\s*$/.test(el.textContent) && el.parentNode) {
el.parentNode.removeChild(el);
}
},
filterLineBreak: function (el) {
if (this.isCommonBlock(el.previousElementSibling)) {
// remove stray br's following common block elements
this.removeWithParent(el);
} else if (this.isCommonBlock(el.parentNode) && (el.parentNode.firstChild === el || el.parentNode.lastChild === el)) {
// remove br's just inside open or close tags of a div/p
this.removeWithParent(el);
} else if (el.parentNode && el.parentNode.childElementCount === 1 && el.parentNode.textContent === '') {
// and br's that are the only child of elements other than div/p
this.removeWithParent(el);
}
},
// remove an element, including its parent, if it is the only element within its parent
removeWithParent: function (el) {
if (el && el.parentNode) {
if (el.parentNode.parentNode && el.parentNode.childElementCount === 1) {
el.parentNode.parentNode.removeChild(el.parentNode);
} else {
el.parentNode.removeChild(el);
}
}
},
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 = '' + el.innerHTML + ' ';
} 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 (Util.traverseUp(el, isCEF)) {
return false;
}
// remove empty spans, replace others with their contents
Util.unwrap(el, this.document);
}
}
});
}());
var Placeholder;
(function () {
'use strict';
/*global Extension */
Placeholder = Extension.extend({
name: 'placeholder',
/* Placeholder Options */
/* text: [string]
* Text to display in the placeholder
*/
text: 'Type your text',
/* hideOnClick: [boolean]
* Should we hide the placeholder on click (true) or when user starts typing (false)
*/
hideOnClick: true,
init: function () {
this.initPlaceholders();
this.attachEventHandlers();
},
initPlaceholders: function () {
this.getEditorElements().forEach(function (el) {
if (!el.getAttribute('data-placeholder')) {
el.setAttribute('data-placeholder', this.text);
}
this.updatePlaceholder(el);
}, this);
},
destroy: function () {
this.getEditorElements().forEach(function (el) {
if (el.getAttribute('data-placeholder') === this.text) {
el.removeAttribute('data-placeholder');
}
}, this);
},
showPlaceholder: function (el) {
if (el) {
el.classList.add('medium-editor-placeholder');
}
},
hidePlaceholder: function (el) {
if (el) {
el.classList.remove('medium-editor-placeholder');
}
},
updatePlaceholder: function (el) {
// if one of these element ('img, blockquote, ul, ol') are found inside the given element, we won't display the placeholder
if (!(el.querySelector('img, blockquote, ul, ol')) && el.textContent.replace(/^\s+|\s+$/g, '') === '') {
return this.showPlaceholder(el);
}
this.hidePlaceholder(el);
},
attachEventHandlers: function () {
// Custom events
this.subscribe('blur', this.handleExternalInteraction.bind(this));
// Check placeholder on blur
this.subscribe('editableBlur', this.handleBlur.bind(this));
// if we don't want the placeholder to be removed on click but when user start typing
if (this.hideOnClick) {
this.subscribe('editableClick', this.handleHidePlaceholderEvent.bind(this));
} else {
this.subscribe('editableKeyup', this.handleBlur.bind(this));
}
// Events where we always hide the placeholder
this.subscribe('editableKeypress', this.handleHidePlaceholderEvent.bind(this));
this.subscribe('editablePaste', this.handleHidePlaceholderEvent.bind(this));
},
handleHidePlaceholderEvent: function (event, element) {
// Events where we hide the placeholder
this.hidePlaceholder(element);
},
handleBlur: function (event, element) {
// Update placeholder for element that lost focus
this.updatePlaceholder(element);
},
handleExternalInteraction: function () {
// Update all placeholders
this.initPlaceholders();
}
});
}());
var Toolbar;
(function () {
'use strict';
Toolbar = function (instance) {
this.base = instance;
this.options = instance.options;
this.initThrottledMethods();
};
Toolbar.prototype = {
// Toolbar creation/deletion
createToolbar: function () {
var toolbar = this.base.options.ownerDocument.createElement('div');
toolbar.id = 'medium-editor-toolbar-' + this.base.id;
toolbar.className = 'medium-editor-toolbar';
if (this.options.staticToolbar) {
toolbar.className += ' static-toolbar';
} else {
toolbar.className += ' stalker-toolbar';
}
toolbar.appendChild(this.createToolbarButtons());
// Add any forms that extensions may have
this.base.commands.forEach(function (extension) {
if (extension.hasForm) {
toolbar.appendChild(extension.getForm());
}
});
this.attachEventHandlers();
return toolbar;
},
createToolbarButtons: function () {
var ul = this.base.options.ownerDocument.createElement('ul'),
li,
btn,
buttons,
extension;
ul.id = 'medium-editor-toolbar-actions' + this.base.id;
ul.className = 'medium-editor-toolbar-actions clearfix';
ul.style.display = 'block';
this.base.options.buttons.forEach(function (button) {
extension = this.base.getExtensionByName(button);
if (typeof extension.getButton === 'function') {
btn = extension.getButton(this.base);
li = this.base.options.ownerDocument.createElement('li');
if (Util.isElement(btn)) {
li.appendChild(btn);
} else {
li.innerHTML = btn;
}
ul.appendChild(li);
}
}.bind(this));
buttons = ul.querySelectorAll('button');
if (buttons.length > 0) {
buttons[0].classList.add(this.options.firstButtonClass);
buttons[buttons.length - 1].classList.add(this.options.lastButtonClass);
}
return ul;
},
destroy: function () {
if (this.toolbar) {
if (this.toolbar.parentNode) {
this.toolbar.parentNode.removeChild(this.toolbar);
}
delete this.toolbar;
}
},
// TODO: deprecate
deactivate: function () {
Util.deprecatedMethod.call(this, 'deactivate', 'destroy', arguments, 'v5.0.0');
},
// Toolbar accessors
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 = 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.base.subscribe('blur', this.handleBlur.bind(this));
this.base.subscribe('focus', this.handleFocus.bind(this));
// Updating the state of the toolbar as things change
this.base.subscribe('editableClick', this.handleEditableClick.bind(this));
this.base.subscribe('editableKeyup', this.handleEditableKeyup.bind(this));
// Handle mouseup on document for updating the selection in the toolbar
this.base.on(this.options.ownerDocument.documentElement, 'mouseup', this.handleDocumentMouseup.bind(this));
// Add a scroll event for sticky toolbar
if (this.options.staticToolbar && this.options.stickyToolbar) {
// On scroll (capture), re-position the toolbar
this.base.on(this.options.contentWindow, 'scroll', this.handleWindowScroll.bind(this), true);
}
// On resize, re-position the toolbar
this.base.on(this.options.contentWindow, '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 &&
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.base.trigger('showToolbar', {}, this.base.getFocusedElement());
if (typeof this.options.onShowToolbar === 'function') {
Util.deprecated('onShowToolbar', 'the showToolbar custom event', 'v5.0.0');
this.options.onShowToolbar();
}
}
},
hideToolbar: function () {
if (this.isDisplayed()) {
this.getToolbarElement().classList.remove('medium-editor-toolbar-active');
this.base.trigger('hideToolbar', {}, this.base.getFocusedElement());
this.base.commands.forEach(function (extension) {
if (typeof extension.onHide === 'function') {
Util.deprecated('onHide', 'the hideToolbar custom event', 'v5.0.0');
extension.onHide();
}
});
if (typeof this.options.onHideToolbar === 'function') {
Util.deprecated('onHideToolbar', 'the hideToolbar custom event', 'v5.0.0');
this.options.onHideToolbar();
}
}
},
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.base.commands.forEach(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('<(' + Util.parentElements.join('|') + ')[^>]*>', 'g'),
selectionHTML = Selection.getSelectionHtml.call(this).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.options.contentWindow.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:
* foo bar
*
* If the text 'bar' is selected, most browsers will have the selectionRange start at the beginning
* of the 'bar' span. However, there are cases where firefox will have the selectionRange start
* at the end of the 'foo' span. The contenteditable behavior will be ok, but if there are any
* properties on the 'bar' span, they won't be reflected accurately in the toolbar
* (ie 'Bold' button wouldn't be active)
*
* So, for cases where the selectionRange start is at the end of an element/node, find the next
* adjacent text node that actually has content in it, and move the selectionRange start there.
*/
if (this.options.standardizeSelectionStart &&
selectionRange.startContainer.nodeValue &&
(selectionRange.startOffset === selectionRange.startContainer.nodeValue.length)) {
var adjacentNode = Util.findAdjacentTextNodeWithContent(Selection.getSelectionElement(this.options.contentWindow), selectionRange.startContainer, this.options.ownerDocument);
if (adjacentNode) {
var offset = 0;
while (adjacentNode.nodeValue.substr(offset, 1).trim().length === 0) {
offset = offset + 1;
}
var newRange = this.options.ownerDocument.createRange();
newRange.setStart(adjacentNode, offset);
newRange.setEnd(selectionRange.endContainer, selectionRange.endOffset);
selection.removeAllRanges();
selection.addRange(newRange);
selectionRange = newRange;
}
}
},
checkState: function () {
if (this.base.preventSelectionUpdates) {
return;
}
// If no editable has focus OR selection is inside contenteditable = false
// hide toolbar
if (!this.base.getFocusedElement() ||
Selection.selectionInContentEditableFalse(this.options.contentWindow)) {
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 = Selection.getSelectionElement(this.options.contentWindow);
if (!selectionElement ||
this.base.elements.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.options.updateOnEmptySelection && this.options.staticToolbar) {
return this.showAndUpdateToolbar();
}
// If we don't have a 'valid' selection -> hide toolbar
if (this.options.contentWindow.getSelection().toString().trim() === '' ||
(this.options.allowMultiParagraphSelection === false && this.multipleBlockElementsSelected())) {
return this.hideToolbar();
}
this.showAndUpdateToolbar();
},
// leaving here backward compatibility / statics
getFocusedElement: function () {
return this.base.getFocusedElement();
},
// Updating the toolbar
showAndUpdateToolbar: function () {
this.modifySelection();
this.setToolbarButtonStates();
this.base.trigger('positionToolbar', {}, this.base.getFocusedElement());
this.showToolbarDefaultActions();
this.setToolbarPosition();
},
setToolbarButtonStates: function () {
this.base.commands.forEach(function (extension) {
if (typeof extension.isActive === 'function' &&
typeof extension.setInactive === 'function') {
extension.setInactive();
}
}.bind(this));
this.checkActiveButtons();
},
checkActiveButtons: function () {
var manualStateChecks = [],
queryState = null,
selectionRange = Selection.getSelectionRange(this.options.ownerDocument),
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;
}
parentNode = Selection.getSelectedParentElement(selectionRange);
// Loop through all commands
this.base.commands.forEach(function (command) {
// For those commands where we can use document.queryCommandState(), do so
if (typeof command.queryCommandState === 'function') {
queryState = command.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 command.setActive === 'function') {
command.setActive();
}
return;
}
}
// We can't use queryCommandState for this command, so add to manualStateChecks
manualStateChecks.push(command);
});
// Climb up the DOM and do manual checks for whether a certain command is currently enabled for this node
while (parentNode.tagName !== undefined && Util.parentElements.indexOf(parentNode.tagName.toLowerCase) === -1) {
manualStateChecks.forEach(updateExtensionState);
// we can abort the search upwards if we leave the contentEditable element
if (this.base.elements.indexOf(parentNode) !== -1) {
break;
}
parentNode = parentNode.parentNode;
}
},
// Positioning toolbar
positionToolbarIfShown: function () {
if (this.isDisplayed()) {
this.setToolbarPosition();
}
},
setToolbarPosition: function () {
var container = this.base.getFocusedElement(),
selection = this.options.contentWindow.getSelection(),
anchorPreview;
// If there isn't a valid selection, bail
if (!container) {
return this;
}
if (this.options.staticToolbar) {
this.showToolbar();
this.positionStaticToolbar(container);
} else if (!selection.isCollapsed) {
this.showToolbar();
this.positionToolbar(selection);
}
anchorPreview = this.base.getExtensionByName('anchor-preview');
if (anchorPreview && typeof anchorPreview.hidePreview === 'function') {
anchorPreview.hidePreview();
}
},
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.options.ownerDocument.documentElement && this.options.ownerDocument.documentElement.scrollTop) || this.options.ownerDocument.body.scrollTop,
windowWidth = this.options.contentWindow.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.options.stickyToolbar) {
// If it's beyond the height of the editor, position it at the bottom of the editor
if (scrollTop > (containerTop + container.offsetHeight - toolbarHeight)) {
toolbarElement.style.top = (containerTop + container.offsetHeight - toolbarHeight) + 'px';
toolbarElement.classList.remove('sticky-toolbar');
// Stick the toolbar to the top of the window
} else if (scrollTop > (containerTop - toolbarHeight)) {
toolbarElement.classList.add('sticky-toolbar');
toolbarElement.style.top = '0px';
// Normal static toolbar position
} else {
toolbarElement.classList.remove('sticky-toolbar');
toolbarElement.style.top = containerTop - toolbarHeight + 'px';
}
} else {
toolbarElement.style.top = containerTop - toolbarHeight + 'px';
}
switch (this.options.toolbarAlign) {
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';
var windowWidth = this.options.contentWindow.innerWidth,
range = selection.getRangeAt(0),
boundary = range.getBoundingClientRect(),
middleBoundary = (boundary.left + boundary.right) / 2,
toolbarElement = this.getToolbarElement(),
toolbarHeight = toolbarElement.offsetHeight,
toolbarWidth = toolbarElement.offsetWidth,
halfOffsetWidth = toolbarWidth / 2,
buttonHeight = 50,
defaultLeft = this.options.diffLeft - halfOffsetWidth;
if (boundary.top < buttonHeight) {
toolbarElement.classList.add('medium-toolbar-arrow-over');
toolbarElement.classList.remove('medium-toolbar-arrow-under');
toolbarElement.style.top = buttonHeight + boundary.bottom - this.options.diffTop + this.options.contentWindow.pageYOffset - toolbarHeight + 'px';
} else {
toolbarElement.classList.add('medium-toolbar-arrow-under');
toolbarElement.classList.remove('medium-toolbar-arrow-over');
toolbarElement.style.top = boundary.top + this.options.diffTop + this.options.contentWindow.pageYOffset - toolbarHeight + 'px';
}
if (middleBoundary < halfOffsetWidth) {
toolbarElement.style.left = defaultLeft + halfOffsetWidth + 'px';
} else if ((windowWidth - middleBoundary) < halfOffsetWidth) {
toolbarElement.style.left = windowWidth + defaultLeft - halfOffsetWidth + 'px';
} else {
toolbarElement.style.left = defaultLeft + middleBoundary + 'px';
}
}
};
}());
var extensionDefaults;
(function () {
// for now this is empty because nothing interally uses an Extension default.
// as they are converted, provide them here.
extensionDefaults = {
button: Button,
form: FormExtension,
anchor: AnchorForm,
anchorPreview: AnchorPreview,
autoLink: AutoLink,
fontSize: FontSizeForm,
imageDragging: ImageDragging,
paste: PasteHandler,
placeholder: Placeholder
};
})();
function MediumEditor(elements, options) {
'use strict';
return this.init(elements, options);
}
(function () {
'use strict';
// Event handlers that shouldn't be exposed externally
function handleDisabledEnterKeydown(event, element) {
if (this.options.disableReturn || element.getAttribute('data-disable-return')) {
event.preventDefault();
} else if (this.options.disableDoubleReturn || element.getAttribute('data-disable-double-return')) {
var node = Selection.getSelectionStart(this.options.ownerDocument);
// if current text selection is empty OR previous sibling text is empty
if ((node && node.textContent.trim() === '') ||
(node.previousElementSibling && node.previousElementSibling.textContent.trim() === '')) {
event.preventDefault();
}
}
}
function handleTabKeydown(event) {
// Override tab only for pre nodes
var node = Selection.getSelectionStart(this.options.ownerDocument),
tag = node && node.tagName.toLowerCase();
if (tag === 'pre') {
event.preventDefault();
Util.insertHTMLCommand(this.options.ownerDocument, ' ');
}
// Tab to indent list structures!
if (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 = Selection.getSelectionStart(this.options.ownerDocument),
tagName = node.tagName.toLowerCase(),
isEmpty = /^(\s+| )?$/i,
isHeader = /h\d/i;
if (Util.isKey(event, [Util.keyCode.BACKSPACE, Util.keyCode.ENTER]) &&
// has a preceeding sibling
node.previousElementSibling &&
// in a header
isHeader.test(tagName) &&
// at the very end of the block
Selection.getCaretOffsets(node).left === 0) {
if (Util.isKey(event, Util.keyCode.BACKSPACE) && isEmpty.test(node.previousElementSibling.innerHTML)) {
// backspacing the begining of a header into an empty previous element will
// change the tagName of the current node to prevent one
// instead delete previous node and cancel the event.
node.previousElementSibling.parentNode.removeChild(node.previousElementSibling);
event.preventDefault();
} else if (Util.isKey(event, Util.keyCode.ENTER)) {
// hitting return in the begining of a header will create empty header elements before the current one
// instead, make "
" element, which are what happens if you hit return in an empty paragraph
p = this.options.ownerDocument.createElement('p');
p.innerHTML = ' ';
node.previousElementSibling.parentNode.insertBefore(p, node);
event.preventDefault();
}
} else if (Util.isKey(event, Util.keyCode.DELETE) &&
// between two sibling elements
node.nextElementSibling &&
node.previousElementSibling &&
// not in a header
!isHeader.test(tagName) &&
// in an empty tag
isEmpty.test(node.innerHTML) &&
// when the next tag *is* a header
isHeader.test(node.nextElementSibling.tagName)) {
// hitting delete in an empty element preceding a header, ex:
// [CURSOR]
Header
// 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
Selection.moveCursor(this.options.ownerDocument, node.nextElementSibling);
node.previousElementSibling.parentNode.removeChild(node);
event.preventDefault();
} else if (Util.isKey(event, Util.keyCode.BACKSPACE) &&
tagName === 'li' &&
// hitting backspace inside an empty li
isEmpty.test(node.innerHTML) &&
// is first element (no preceeding siblings)
!node.previousElementSibling &&
// parent also does not have a sibling
!node.parentElement.previousElementSibling &&
// is not the only li in a list
node.nextElementSibling &&
node.nextElementSibling.tagName.toLowerCase() === 'li') {
// backspacing in an empty first list element in the first list (with more elements) ex:
//
// will remove the first 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 = ' ';
node.parentElement.parentElement.insertBefore(p, node.parentElement);
// move the cursor into the new paragraph
Selection.moveCursor(this.options.ownerDocument, p);
// remove the list element
node.parentElement.removeChild(node);
event.preventDefault();
}
}
function handleKeyup(event) {
var node = Selection.getSelectionStart(this.options.ownerDocument),
tagName;
if (!node) {
return;
}
if (node.getAttribute('data-medium-element') && node.children.length === 0) {
this.options.ownerDocument.execCommand('formatBlock', false, 'p');
}
if (Util.isKey(event, Util.keyCode.ENTER) && !Util.isListItem(node)) {
tagName = node.tagName.toLowerCase();
// For anchor tags, unlink
if (tagName === 'a') {
this.options.ownerDocument.execCommand('unlink', false, null);
} else if (!event.shiftKey && !event.ctrlKey) {
// only format block if this is not a header tag
if (!/h\d/.test(tagName)) {
this.options.ownerDocument.execCommand('formatBlock', false, 'p');
}
}
}
}
// Internal helper methods which shouldn't be exposed externally
function createElementsArray(selector) {
if (!selector) {
selector = [];
}
// If string, use as query selector
if (typeof selector === 'string') {
selector = this.options.ownerDocument.querySelectorAll(selector);
}
// If element, put into array
if (Util.isElement(selector)) {
selector = [selector];
}
// Convert NodeList (or other array like object) into an array
var elements = Array.prototype.slice.apply(selector);
// Loop through elements and convert textarea's into divs
this.elements = [];
elements.forEach(function (element, index) {
if (element.tagName.toLowerCase() === 'textarea') {
this.elements.push(createContentEditable.call(this, element, index));
} else {
this.elements.push(element);
}
}, this);
}
function setExtensionDefaults(extension, defaults) {
Object.keys(defaults).forEach(function (prop) {
if (extension[prop] === undefined) {
extension[prop] = defaults[prop];
}
});
return extension;
}
function initExtension(extension, name, instance) {
if (typeof extension.parent !== 'undefined') {
Util.warn('Extension .parent property has been deprecated. ' +
'The .base property for extensions will always be set to MediumEditor in version 5.0.0');
}
var extensionDefaults = {
'window': instance.options.contentWindow,
'document': instance.options.ownerDocument
};
// TODO: Deprecated (Remove .parent check in v5.0.0)
if (extension.parent !== false) {
extensionDefaults.base = instance;
}
// Add default options into the extension
extension = setExtensionDefaults(extension, extensionDefaults);
// Call init on the extension
if (typeof extension.init === 'function') {
// Passing instance into init() will be deprecated in v5.0.0
extension.init(instance);
}
// Set extension name (if not already set)
if (!extension.name) {
extension.name = name;
}
return extension;
}
function shouldAddDefaultAnchorPreview() {
var i,
shouldAdd = false;
// TODO: deprecated
// If anchor-preview is disabled, don't add
if (this.options.disableAnchorPreview) {
return false;
}
// If anchor-preview is disabled, don't add
if (this.options.anchorPreview === false) {
return false;
}
// If anchor-preview extension has been overriden, don't add
if (this.options.extensions['anchor-preview']) {
return false;
}
// If toolbar is disabled, don't add
if (this.options.disableToolbar) {
return false;
}
// If all elements have 'data-disable-toolbar' attribute, don't add
for (i = 0; i < this.elements.length; i += 1) {
if (!this.elements[i].getAttribute('data-disable-toolbar')) {
shouldAdd = true;
break;
}
}
return shouldAdd;
}
function shouldAddDefaultPlaceholder() {
if (this.options.extensions['placeholder']) {
return false;
}
// TODO: deprecated
if (this.options.disablePlaceholders) {
return false;
}
return this.options.placeholder !== false;
}
function shouldAddDefaultAutoLink() {
if (this.options.extensions['auto-link']) {
return false;
}
return this.options.autoLink !== false;
}
function shouldAddDefaultImageDragging() {
if (this.options.extensions['image-dragging']) {
return false;
}
return this.options.imageDragging !== false;
}
function createContentEditable(textarea, id) {
var div = this.options.ownerDocument.createElement('div'),
uniqueId = 'medium-editor-' + Date.now() + '-' + id,
attributesToClone = [
'data-disable-editing',
'data-disable-toolbar',
'data-placeholder',
'data-disable-return',
'data-disable-double-return',
'data-disable-preview',
'spellcheck'
];
div.className = textarea.className;
div.id = uniqueId;
div.innerHTML = textarea.value;
div.setAttribute('medium-editor-textarea-id', id);
attributesToClone.forEach(function (attr) {
if (textarea.hasAttribute(attr)) {
div.setAttribute(attr, textarea.getAttribute(attr));
}
});
textarea.classList.add('medium-editor-hidden');
textarea.setAttribute('medium-editor-textarea-id', id);
textarea.parentNode.insertBefore(
div,
textarea
);
return div;
}
function initElements() {
this.elements.forEach(function (element, index) {
if (!this.options.disableEditing && !element.getAttribute('data-disable-editing')) {
element.setAttribute('contentEditable', true);
element.setAttribute('spellcheck', this.options.spellcheck);
}
element.setAttribute('data-medium-element', true);
element.setAttribute('role', 'textbox');
element.setAttribute('aria-multiline', true);
element.setAttribute('medium-editor-index', index);
if (element.hasAttribute('medium-editor-textarea-id')) {
this.on(element, 'input', function (event) {
var target = event.target,
textarea = target.parentNode.querySelector('textarea[medium-editor-textarea-id="' + target.getAttribute('medium-editor-textarea-id') + '"]');
if (textarea) {
textarea.value = this.serialize()[target.id].value;
}
}.bind(this));
}
}, this);
}
function initToolbar() {
if (this.toolbar || this.options.disableToolbar) {
return false;
}
var addToolbar = this.elements.some(function (element) {
return !element.getAttribute('data-disable-toolbar');
});
if (addToolbar) {
this.toolbar = new Toolbar(this);
this.options.elementsContainer.appendChild(this.toolbar.getToolbarElement());
}
}
function attachHandlers() {
var i;
// attach to tabs
this.subscribe('editableKeydownTab', handleTabKeydown.bind(this));
// Bind keys which can create or destroy a block element: backspace, delete, return
this.subscribe('editableKeydownDelete', handleBlockDeleteKeydowns.bind(this));
this.subscribe('editableKeydownEnter', handleBlockDeleteKeydowns.bind(this));
// disabling return or double return
if (this.options.disableReturn || this.options.disableDoubleReturn) {
this.subscribe('editableKeydownEnter', handleDisabledEnterKeydown.bind(this));
} else {
for (i = 0; i < this.elements.length; i += 1) {
if (this.elements[i].getAttribute('data-disable-return') || this.elements[i].getAttribute('data-disable-double-return')) {
this.subscribe('editableKeydownEnter', handleDisabledEnterKeydown.bind(this));
break;
}
}
}
// if we're not disabling return, add a handler to help handle cleanup
// for certain cases when enter is pressed
if (!this.options.disableReturn) {
this.elements.forEach(function (element) {
if (!element.getAttribute('data-disable-return')) {
this.on(element, 'keyup', handleKeyup.bind(this));
}
}, this);
}
}
function initPlaceholder(options) {
// Backwards compatability
var defaultsBC = {
text: (typeof this.options.placeholder === 'string') ? this.options.placeholder : undefined // deprecated
};
return new MediumEditor.extensions.placeholder(
Util.extend({}, options, defaultsBC)
);
}
function initAnchorPreview(options) {
// Backwards compatability
var defaultsBC = {
hideDelay: this.options.anchorPreviewHideDelay, // deprecated
diffLeft: this.options.diffLeft, // deprecated (should use .getEditorOption() instead)
diffTop: this.options.diffTop, // deprecated (should use .getEditorOption() instead)
elementsContainer: this.options.elementsContainer // deprecated (should use .getEditorOption() instead)
};
return new MediumEditor.extensions.anchorPreview(
Util.extend({}, options, defaultsBC)
);
}
function initAnchorForm(options) {
// Backwards compatability
var defaultsBC = {
customClassOption: this.options.anchorButton ? (this.options.anchorButtonClass || 'btn') : undefined, // deprecated
linkValidation: this.options.checkLinkFormat, //deprecated
placeholderText: this.options.anchorInputPlaceholder, // deprecated
targetCheckbox: this.options.anchorTarget, // deprecated
targetCheckboxText: this.options.anchorInputCheckboxLabel // deprecated
};
return new MediumEditor.extensions.anchor(
Util.extend({}, options, defaultsBC)
);
}
function initPasteHandler(options) {
// Backwards compatability
var defaultsBC = {
forcePlainText: this.options.forcePlainText, // deprecated
cleanPastedHTML: this.options.cleanPastedHTML, // deprecated
disableReturn: this.options.disableReturn, // deprecated (should use .getEditorOption() instead)
targetBlank: this.options.targetBlank // deprecated (should use .getEditorOption() instead)
};
return new MediumEditor.extensions.paste(
Util.extend({}, options, defaultsBC)
);
}
function initCommands() {
var buttons = this.options.buttons,
extensions = this.options.extensions,
ext,
name;
this.commands = [];
buttons.forEach(function (buttonName) {
if (extensions[buttonName]) {
ext = initExtension(extensions[buttonName], buttonName, this);
this.commands.push(ext);
} else if (buttonName === 'anchor') {
ext = initExtension(initAnchorForm.call(this, this.options.anchor), 'anchor', this);
this.commands.push(ext);
} else if (buttonName === 'fontsize') {
ext = initExtension(new MediumEditor.extensions.fontSize(), buttonName, this);
this.commands.push(ext);
} else if (ButtonsData.hasOwnProperty(buttonName)) {
ext = initExtension(new MediumEditor.extensions.button(ButtonsData[buttonName]), buttonName, this);
this.commands.push(ext);
}
}, this);
for (name in extensions) {
if (extensions.hasOwnProperty(name) && buttons.indexOf(name) === -1) {
ext = initExtension(extensions[name], name, this);
this.commands.push(ext);
}
}
// Only add default paste extension if it wasn't overriden
if (!this.options.extensions['paste']) {
this.commands.push(initExtension(initPasteHandler.call(this, this.options.paste), 'paste', this));
}
// Add AnchorPreview as extension if needed
if (shouldAddDefaultAnchorPreview.call(this)) {
this.commands.push(initExtension(initAnchorPreview.call(this, this.options.anchorPreview), 'anchor-preview', this));
}
if (shouldAddDefaultAutoLink.call(this)) {
this.commands.push(initExtension(new MediumEditor.extensions.autoLink(), 'auto-link', this));
}
if (shouldAddDefaultImageDragging.call(this)) {
this.commands.push(initExtension(new MediumEditor.extensions.imageDragging(), 'image-dragging', this));
}
if (shouldAddDefaultPlaceholder.call(this)) {
var placeholderOpts = (typeof this.options.placeholder === 'string') ? {} : this.options.placeholder;
this.commands.push(initExtension(initPlaceholder.call(this, placeholderOpts), 'placeholder', this));
}
}
function mergeOptions(defaults, options) {
var deprecatedProperties = [
['forcePlainText', 'paste.forcePlainText'],
['cleanPastedHTML', 'paste.cleanPastedHTML'],
['anchorInputPlaceholder', 'anchor.placeholderText'],
['checkLinkFormat', 'anchor.linkValidation'],
['anchorButton', 'anchor.customClassOption'],
['anchorButtonClass', 'anchor.customClassOption'],
['anchorTarget', 'anchor.targetCheckbox'],
['anchorInputCheckboxLabel', 'anchor.targetCheckboxText'],
['anchorPreviewHideDelay', 'anchorPreview.hideDelay'],
['disableAnchorPreview', 'anchorPreview: false'],
['disablePlaceholders', 'placeholder: false'],
['onShowToolbar', 'showToolbar custom event'],
['onHideToolbar', 'hideToolbar custom event']
];
// warn about using deprecated properties
if (options) {
deprecatedProperties.forEach(function (pair) {
if (options.hasOwnProperty(pair[0]) && options[pair[0]] !== undefined) {
Util.deprecated(pair[0], pair[1], 'v5.0.0');
}
});
if (options.hasOwnProperty('placeholder') && typeof options.placeholder === 'string') {
Util.deprecated('placeholder', 'placeholder.text', 'v5.0.0');
}
}
return Util.defaults({}, options, defaults);
}
function execActionInternal(action, opts) {
/*jslint regexp: true*/
var appendAction = /^append-(.+)$/gi,
match;
/*jslint regexp: false*/
// Actions starting with 'append-' should attempt to format a block of text ('formatBlock') using a specific
// type of block element (ie append-blockquote, append-h1, append-pre, etc.)
match = appendAction.exec(action);
if (match) {
return Util.execFormatBlock(this.options.ownerDocument, match[1]);
}
if (action === 'fontSize') {
return this.options.ownerDocument.execCommand('fontSize', false, opts.size);
}
if (action === 'createLink') {
return this.createLink(opts);
}
if (action === 'image') {
return this.options.ownerDocument.execCommand('insertImage', false, this.options.contentWindow.getSelection());
}
return this.options.ownerDocument.execCommand(action, false, null);
}
// deprecate
MediumEditor.statics = {
ButtonsData: ButtonsData,
DefaultButton: DefaultButton,
AnchorExtension: AnchorExtension,
FontSizeExtension: FontSizeExtension,
Toolbar: Toolbar,
AnchorPreview: AnchorPreviewDeprecated
};
MediumEditor.Extension = Extension;
MediumEditor.extensions = extensionDefaults;
MediumEditor.util = Util;
MediumEditor.selection = Selection;
MediumEditor.prototype = {
defaults: editorDefaults,
// NOT DOCUMENTED - exposed for backwards compatability
init: function (elements, options) {
var uniqueId = 1;
this.options = mergeOptions.call(this, this.defaults, options);
this.origElements = elements;
if (!this.options.elementsContainer) {
this.options.elementsContainer = this.options.ownerDocument.body;
}
while (this.options.elementsContainer.querySelector('#medium-editor-toolbar-' + uniqueId)) {
uniqueId = uniqueId + 1;
}
this.id = uniqueId;
return this.setup();
},
setup: function () {
if (this.isActive) {
return;
}
createElementsArray.call(this, this.origElements);
if (this.elements.length === 0) {
return;
}
this.events = new Events(this);
this.isActive = true;
// Call initialization helpers
initElements.call(this);
initCommands.call(this);
initToolbar.call(this);
attachHandlers.call(this);
},
destroy: function () {
if (!this.isActive) {
return;
}
this.isActive = false;
this.commands.forEach(function (extension) {
if (typeof extension.destroy === 'function') {
extension.destroy();
} else if (typeof extension.deactivate === 'function') {
Util.warn('Extension .deactivate() function has been deprecated. Use .destroy() instead. This will be removed in version 5.0.0');
extension.deactivate();
}
}, this);
if (this.toolbar !== undefined) {
this.toolbar.destroy();
delete this.toolbar;
}
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;
}
element.removeAttribute('contentEditable');
element.removeAttribute('spellcheck');
element.removeAttribute('data-medium-element');
element.removeAttribute('medium-editor-index');
element.removeAttribute('role');
element.removeAttribute('aria-multiline');
// Remove any elements created for textareas
if (element.hasAttribute('medium-editor-textarea-id')) {
var textarea = element.parentNode.querySelector('textarea[medium-editor-textarea-id="' + element.getAttribute('medium-editor-textarea-id') + '"]');
if (textarea) {
// Un-hide the textarea
textarea.classList.remove('medium-editor-hidden');
}
if (element.parentNode) {
element.parentNode.removeChild(element);
}
}
}, this);
this.elements = [];
},
on: function (target, event, listener, useCapture) {
this.events.attachDOMEvent(target, event, listener, useCapture);
},
off: function (target, event, listener, useCapture) {
this.events.detachDOMEvent(target, event, listener, useCapture);
},
subscribe: function (event, listener) {
this.events.attachCustomEvent(event, listener);
},
unsubscribe: function (event, listener) {
this.events.detachCustomEvent(event, listener);
},
createEvent: function () {
Util.warn('.createEvent() has been deprecated and is no longer needed. ' +
'You can attach and trigger custom events without calling this method. This will be removed in v5.0.0');
},
trigger: function (name, data, editable) {
this.events.triggerCustomEvent(name, data, editable);
},
delay: function (fn) {
var self = this;
return setTimeout(function () {
if (self.isActive) {
fn();
}
}, this.options.delay);
},
serialize: function () {
var i,
elementid,
content = {};
for (i = 0; i < this.elements.length; i += 1) {
elementid = (this.elements[i].id !== '') ? this.elements[i].id : 'element-' + i;
content[elementid] = {
value: this.elements[i].innerHTML.trim()
};
}
return content;
},
getExtensionByName: function (name) {
var extension;
if (this.commands && this.commands.length) {
this.commands.some(function (ext) {
if (ext.name === name) {
extension = ext;
return true;
}
return false;
});
}
return extension;
},
/**
* NOT DOCUMENTED - exposed for backwards compatability
* Helper function to call a method with a number of parameters on all registered extensions.
* The function assures that the function exists before calling.
*
* @param {string} funcName name of the function to call
* @param [args] arguments passed into funcName
*/
callExtensions: function (funcName) {
if (arguments.length < 1) {
return;
}
var args = Array.prototype.slice.call(arguments, 1),
ext,
name;
for (name in this.options.extensions) {
if (this.options.extensions.hasOwnProperty(name)) {
ext = this.options.extensions[name];
if (ext[funcName] !== undefined) {
ext[funcName].apply(ext, args);
}
}
}
return this;
},
stopSelectionUpdates: function () {
this.preventSelectionUpdates = true;
},
startSelectionUpdates: function () {
this.preventSelectionUpdates = false;
},
checkSelection: function () {
if (this.toolbar) {
this.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') {
Util.cleanListDOM(this.options.ownerDocument, this.getSelectedParentElement());
}
this.checkSelection();
return result;
},
getSelectedParentElement: function (range) {
if (range === undefined) {
range = this.options.contentWindow.getSelection().getRangeAt(0);
}
return Selection.getSelectedParentElement(range);
},
// NOT DOCUMENTED - exposed as extension helper
hideToolbarDefaultActions: function () {
if (this.toolbar) {
this.toolbar.hideToolbarDefaultActions();
}
return this;
},
// NOT DOCUMENTED - exposed as extension helper and for backwards compatability
setToolbarPosition: function () {
if (this.toolbar) {
this.toolbar.setToolbarPosition();
}
},
selectAllContents: function () {
var currNode = Selection.getSelectionElement(this.options.contentWindow);
if (currNode) {
// Move to the lowest descendant node that still selects all of the contents
while (currNode.children.length === 1) {
currNode = currNode.children[0];
}
this.selectElement(currNode);
}
},
selectElement: function (element) {
Selection.selectNode(element, this.options.ownerDocument);
var selElement = 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;
},
// http://stackoverflow.com/questions/17678843/cant-restore-selection-after-html-modify-even-if-its-the-same-html
// Tim Down
// TODO: move to selection.js and clean up old methods there
exportSelection: function () {
var selectionState = null,
selection = this.options.contentWindow.getSelection(),
range,
preSelectionRange,
start,
editableElementIndex = -1;
if (selection.rangeCount > 0) {
range = selection.getRangeAt(0);
preSelectionRange = range.cloneRange();
// Find element current selection is inside
this.elements.some(function (el, index) {
if (el === range.startContainer || Util.isDescendant(el, range.startContainer)) {
editableElementIndex = index;
return true;
}
return false;
});
if (editableElementIndex > -1) {
preSelectionRange.selectNodeContents(this.elements[editableElementIndex]);
preSelectionRange.setEnd(range.startContainer, range.startOffset);
start = preSelectionRange.toString().length;
selectionState = {
start: start,
end: start + range.toString().length,
editableElementIndex: editableElementIndex
};
// If start = 0 there may still be an empty paragraph before it, but we don't care.
if (start !== 0) {
var emptyBlocksIndex = Selection.getIndexRelativeToAdjacentEmptyBlocks(
this.options.ownerDocument,
this.elements[editableElementIndex],
range.startContainer,
range.startOffset);
if (emptyBlocksIndex !== -1) {
selectionState.emptyBlocksIndex = emptyBlocksIndex;
}
}
}
}
if (selectionState !== null && selectionState.editableElementIndex === 0) {
delete selectionState.editableElementIndex;
}
return selectionState;
},
saveSelection: function () {
this.selectionState = this.exportSelection();
},
// http://stackoverflow.com/questions/17678843/cant-restore-selection-after-html-modify-even-if-its-the-same-html
// Tim Down
// TODO: move to selection.js and clean up old methods there
//
// {object} inSelectionState - the selection to import
// {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 (inSelectionState, favorLaterSelectionAnchor) {
if (!inSelectionState) {
return;
}
var editableElementIndex = inSelectionState.editableElementIndex === undefined ?
0 : inSelectionState.editableElementIndex,
selectionState = {
editableElementIndex: editableElementIndex,
start: inSelectionState.start,
end: inSelectionState.end
},
editableElement = this.elements[selectionState.editableElementIndex],
charIndex = 0,
range = this.options.ownerDocument.createRange(),
nodeStack = [editableElement],
node,
foundStart = false,
stop = false,
i,
sel,
nextCharIndex;
range.setStart(editableElement, 0);
range.collapse(true);
node = nodeStack.pop();
while (!stop && node) {
if (node.nodeType === 3) {
nextCharIndex = charIndex + node.length;
if (!foundStart && selectionState.start >= charIndex && selectionState.start <= nextCharIndex) {
range.setStart(node, selectionState.start - charIndex);
foundStart = true;
}
if (foundStart && selectionState.end >= charIndex && selectionState.end <= nextCharIndex) {
range.setEnd(node, selectionState.end - charIndex);
stop = true;
}
charIndex = nextCharIndex;
} else {
i = node.childNodes.length - 1;
while (i >= 0) {
nodeStack.push(node.childNodes[i]);
i -= 1;
}
}
if (!stop) {
node = nodeStack.pop();
}
}
if (typeof inSelectionState.emptyBlocksIndex !== 'undefined') {
range = Selection.importSelectionMoveCursorPastBlocks(this.options.ownerDocument, editableElement, inSelectionState.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 = Selection.importSelectionMoveCursorPastAnchor(selectionState, range);
}
sel = this.options.contentWindow.getSelection();
sel.removeAllRanges();
sel.addRange(range);
},
restoreSelection: function () {
this.importSelection(this.selectionState);
},
createLink: function (opts) {
var customEvent,
i;
try {
this.events.disableCustomEvent('editableInput');
if (opts.url && opts.url.trim().length > 0) {
this.options.ownerDocument.execCommand('createLink', false, opts.url);
if (this.options.targetBlank || opts.target === '_blank') {
Util.setTargetBlank(Selection.getSelectionStart(this.options.ownerDocument), opts.url);
}
if (opts.buttonClass) {
Util.addClassToAnchors(Selection.getSelectionStart(this.options.ownerDocument), opts.buttonClass);
}
}
// 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 (i = 0; i < this.elements.length; i += 1) {
this.elements[i].dispatchEvent(customEvent);
}
}
} finally {
this.events.enableCustomEvent('editableInput');
}
// Fire our custom editableInput event
this.events.triggerCustomEvent('editableInput', customEvent, this.getFocusedElement());
},
// alias for setup - keeping for backwards compatability
activate: function () {
Util.deprecatedMethod.call(this, 'activate', 'setup', arguments, 'v5.0.0');
},
// alias for destroy - keeping for backwards compatability
deactivate: function () {
Util.deprecatedMethod.call(this, 'deactivate', 'destroy', arguments, 'v5.0.0');
},
cleanPaste: function (text) {
this.getExtensionByName('paste').cleanPaste(text);
},
pasteHTML: function (html, options) {
this.getExtensionByName('paste').pasteHTML(html, options);
}
};
}());
MediumEditor.version = (function (major, minor, revision) {
return {
major: parseInt(major, 10),
minor: parseInt(minor, 10),
revision: parseInt(revision, 10),
toString: function () {
return [major, minor, revision].join('.');
}
};
}).apply(this, ({
// grunt-bump looks for this:
'version': '4.12.8'
}).version.split('.'));
return MediumEditor;
}()));