medium-editor
Version:
Medium.com WYSIWYG editor clone.
1,093 lines (965 loc) • 44.5 kB
JavaScript
/*global NodeFilter*/
(function (window) {
'use strict';
function copyInto(overwrite, dest) {
var prop,
sources = Array.prototype.slice.call(arguments, 2);
dest = dest || {};
for (var i = 0; i < sources.length; i++) {
var source = sources[i];
if (source) {
for (prop in source) {
if (source.hasOwnProperty(prop) &&
typeof source[prop] !== 'undefined' &&
(overwrite || dest.hasOwnProperty(prop) === false)) {
dest[prop] = source[prop];
}
}
}
}
return dest;
}
// https://developer.mozilla.org/en-US/docs/Web/API/Node/contains
// Some browsers (including phantom) don't return true for Node.contains(child)
// if child is a text node. Detect these cases here and use a fallback
// for calls to Util.isDescendant()
var nodeContainsWorksWithTextNodes = false;
try {
var testParent = document.createElement('div'),
testText = document.createTextNode(' ');
testParent.appendChild(testText);
nodeContainsWorksWithTextNodes = testParent.contains(testText);
} catch (exc) {}
var Util = {
// http://stackoverflow.com/questions/17907445/how-to-detect-ie11#comment30165888_17907562
// by rg89
isIE: ((navigator.appName === 'Microsoft Internet Explorer') || ((navigator.appName === 'Netscape') && (new RegExp('Trident/.*rv:([0-9]{1,}[.0-9]{0,})').exec(navigator.userAgent) !== null))),
isEdge: (/Edge\/\d+/).exec(navigator.userAgent) !== null,
// if firefox
isFF: (navigator.userAgent.toLowerCase().indexOf('firefox') > -1),
// http://stackoverflow.com/a/11752084/569101
isMac: (window.navigator.platform.toUpperCase().indexOf('MAC') >= 0),
// https://github.com/jashkenas/underscore
// Lonely letter MUST USE the uppercase code
keyCode: {
BACKSPACE: 8,
TAB: 9,
ENTER: 13,
ESCAPE: 27,
SPACE: 32,
DELETE: 46,
K: 75, // K keycode, and not k
M: 77,
V: 86
},
/**
* Returns true if it's metaKey on Mac, or ctrlKey on non-Mac.
* See #591
*/
isMetaCtrlKey: function (event) {
if ((Util.isMac && event.metaKey) || (!Util.isMac && event.ctrlKey)) {
return true;
}
return false;
},
/**
* Returns true if the key associated to the event is inside keys array
*
* @see : https://github.com/jquery/jquery/blob/0705be475092aede1eddae01319ec931fb9c65fc/src/event.js#L473-L484
* @see : http://stackoverflow.com/q/4471582/569101
*/
isKey: function (event, keys) {
var keyCode = Util.getKeyCode(event);
// it's not an array let's just compare strings!
if (false === Array.isArray(keys)) {
return keyCode === keys;
}
if (-1 === keys.indexOf(keyCode)) {
return false;
}
return true;
},
getKeyCode: function (event) {
var keyCode = event.which;
// getting the key code from event
if (null === keyCode) {
keyCode = event.charCode !== null ? event.charCode : event.keyCode;
}
return keyCode;
},
blockContainerElementNames: [
// elements our editor generates
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'ul', 'li', 'ol',
// all other known block elements
'address', 'article', 'aside', 'audio', 'canvas', 'dd', 'dl', 'dt', 'fieldset',
'figcaption', 'figure', 'footer', 'form', 'header', 'hgroup', 'main', 'nav',
'noscript', 'output', 'section', 'video',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td'
],
emptyElementNames: ['br', 'col', 'colgroup', 'hr', 'img', 'input', 'source', 'wbr'],
extend: function extend(/* dest, source1, source2, ...*/) {
var args = [true].concat(Array.prototype.slice.call(arguments));
return copyInto.apply(this, args);
},
defaults: function defaults(/*dest, source1, source2, ...*/) {
var args = [false].concat(Array.prototype.slice.call(arguments));
return copyInto.apply(this, args);
},
/*
* Create a link around the provided text nodes which must be adjacent to each other and all be
* descendants of the same closest block container. If the preconditions are not met, unexpected
* behavior will result.
*/
createLink: function (document, textNodes, href, target) {
var anchor = document.createElement('a');
Util.moveTextRangeIntoElement(textNodes[0], textNodes[textNodes.length - 1], anchor);
anchor.setAttribute('href', href);
if (target) {
if (target === '_blank') {
anchor.setAttribute('rel', 'noopener noreferrer');
}
anchor.setAttribute('target', target);
}
return anchor;
},
/*
* Given the provided match in the format {start: 1, end: 2} where start and end are indices into the
* textContent of the provided element argument, modify the DOM inside element to ensure that the text
* identified by the provided match can be returned as text nodes that contain exactly that text, without
* any additional text at the beginning or end of the returned array of adjacent text nodes.
*
* The only DOM manipulation performed by this function is splitting the text nodes, non-text nodes are
* not affected in any way.
*/
findOrCreateMatchingTextNodes: function (document, element, match) {
var treeWalker = document.createTreeWalker(element, NodeFilter.SHOW_ALL, null, false),
matchedNodes = [],
currentTextIndex = 0,
startReached = false,
currentNode = null,
newNode = null;
while ((currentNode = treeWalker.nextNode()) !== null) {
if (currentNode.nodeType > 3) {
continue;
} else if (currentNode.nodeType === 3) {
if (!startReached && match.start < (currentTextIndex + currentNode.nodeValue.length)) {
startReached = true;
newNode = Util.splitStartNodeIfNeeded(currentNode, match.start, currentTextIndex);
}
if (startReached) {
Util.splitEndNodeIfNeeded(currentNode, newNode, match.end, currentTextIndex);
}
if (startReached && currentTextIndex === match.end) {
break; // Found the node(s) corresponding to the link. Break out and move on to the next.
} else if (startReached && currentTextIndex > (match.end + 1)) {
throw new Error('PerformLinking overshot the target!'); // should never happen...
}
if (startReached) {
matchedNodes.push(newNode || currentNode);
}
currentTextIndex += currentNode.nodeValue.length;
if (newNode !== null) {
currentTextIndex += newNode.nodeValue.length;
// Skip the newNode as we'll already have pushed it to the matches
treeWalker.nextNode();
}
newNode = null;
} else if (currentNode.tagName.toLowerCase() === 'img') {
if (!startReached && (match.start <= currentTextIndex)) {
startReached = true;
}
if (startReached) {
matchedNodes.push(currentNode);
}
}
}
return matchedNodes;
},
/*
* Given the provided text node and text coordinates, split the text node if needed to make it align
* precisely with the coordinates.
*
* This function is intended to be called from Util.findOrCreateMatchingTextNodes.
*/
splitStartNodeIfNeeded: function (currentNode, matchStartIndex, currentTextIndex) {
if (matchStartIndex !== currentTextIndex) {
return currentNode.splitText(matchStartIndex - currentTextIndex);
}
return null;
},
/*
* Given the provided text node and text coordinates, split the text node if needed to make it align
* precisely with the coordinates. The newNode argument should from the result of Util.splitStartNodeIfNeeded,
* if that function has been called on the same currentNode.
*
* This function is intended to be called from Util.findOrCreateMatchingTextNodes.
*/
splitEndNodeIfNeeded: function (currentNode, newNode, matchEndIndex, currentTextIndex) {
var textIndexOfEndOfFarthestNode,
endSplitPoint;
textIndexOfEndOfFarthestNode = currentTextIndex + currentNode.nodeValue.length +
(newNode ? newNode.nodeValue.length : 0) - 1;
endSplitPoint = matchEndIndex - currentTextIndex -
(newNode ? currentNode.nodeValue.length : 0);
if (textIndexOfEndOfFarthestNode >= matchEndIndex &&
currentTextIndex !== textIndexOfEndOfFarthestNode &&
endSplitPoint !== 0) {
(newNode || currentNode).splitText(endSplitPoint);
}
},
/*
* Take an element, and break up all of its text content into unique pieces such that:
* 1) All text content of the elements are in separate blocks. No piece of text content should span
* across multiple blocks. This means no element return by this function should have
* any blocks as children.
* 2) The union of the textcontent of all of the elements returned here covers all
* of the text within the element.
*
*
* EXAMPLE:
* In the event that we have something like:
*
* <blockquote>
* <p>Some Text</p>
* <ol>
* <li>List Item 1</li>
* <li>List Item 2</li>
* </ol>
* </blockquote>
*
* This function would return these elements as an array:
* [ <p>Some Text</p>, <li>List Item 1</li>, <li>List Item 2</li> ]
*
* Since the <blockquote> and <ol> elements contain blocks within them they are not returned.
* Since the <p> and <li>'s don't contain block elements and cover all the text content of the
* <blockquote> container, they are the elements returned.
*/
splitByBlockElements: function (element) {
if (element.nodeType !== 3 && element.nodeType !== 1) {
return [];
}
var toRet = [],
blockElementQuery = MediumEditor.util.blockContainerElementNames.join(',');
if (element.nodeType === 3 || element.querySelectorAll(blockElementQuery).length === 0) {
return [element];
}
for (var i = 0; i < element.childNodes.length; i++) {
var child = element.childNodes[i];
if (child.nodeType === 3) {
toRet.push(child);
} else if (child.nodeType === 1) {
var blockElements = child.querySelectorAll(blockElementQuery);
if (blockElements.length === 0) {
toRet.push(child);
} else {
toRet = toRet.concat(MediumEditor.util.splitByBlockElements(child));
}
}
}
return toRet;
},
// Find the next node in the DOM tree that represents any text that is being
// displayed directly next to the targetNode (passed as an argument)
// Text that appears directly next to the current node can be:
// - A sibling text node
// - A descendant of a sibling element
// - A sibling text node of an ancestor
// - A descendant of a sibling element of an ancestor
findAdjacentTextNodeWithContent: function findAdjacentTextNodeWithContent(rootNode, targetNode, ownerDocument) {
var pastTarget = false,
nextNode,
nodeIterator = ownerDocument.createNodeIterator(rootNode, NodeFilter.SHOW_TEXT, null, false);
// Use a native NodeIterator to iterate over all the text nodes that are descendants
// of the rootNode. Once past the targetNode, choose the first non-empty text node
nextNode = nodeIterator.nextNode();
while (nextNode) {
if (nextNode === targetNode) {
pastTarget = true;
} else if (pastTarget) {
if (nextNode.nodeType === 3 && nextNode.nodeValue && nextNode.nodeValue.trim().length > 0) {
break;
}
}
nextNode = nodeIterator.nextNode();
}
return nextNode;
},
// Find an element's previous sibling within a medium-editor element
// If one doesn't exist, find the closest ancestor's previous sibling
findPreviousSibling: function (node) {
if (!node || Util.isMediumEditorElement(node)) {
return false;
}
var previousSibling = node.previousSibling;
while (!previousSibling && !Util.isMediumEditorElement(node.parentNode)) {
node = node.parentNode;
previousSibling = node.previousSibling;
}
return previousSibling;
},
isDescendant: function isDescendant(parent, child, checkEquality) {
if (!parent || !child) {
return false;
}
if (parent === child) {
return !!checkEquality;
}
// If parent is not an element, it can't have any descendants
if (parent.nodeType !== 1) {
return false;
}
if (nodeContainsWorksWithTextNodes || child.nodeType !== 3) {
return parent.contains(child);
}
var node = child.parentNode;
while (node !== null) {
if (node === parent) {
return true;
}
node = node.parentNode;
}
return false;
},
// https://github.com/jashkenas/underscore
isElement: function isElement(obj) {
return !!(obj && obj.nodeType === 1);
},
// https://github.com/jashkenas/underscore
throttle: function (func, wait) {
var THROTTLE_INTERVAL = 50,
context,
args,
result,
timeout = null,
previous = 0,
later = function () {
previous = Date.now();
timeout = null;
result = func.apply(context, args);
if (!timeout) {
context = args = null;
}
};
if (!wait && wait !== 0) {
wait = THROTTLE_INTERVAL;
}
return function () {
var now = Date.now(),
remaining = wait - (now - previous);
context = this;
args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) {
context = args = null;
}
} else if (!timeout) {
timeout = setTimeout(later, remaining);
}
return result;
};
},
traverseUp: function (current, testElementFunction) {
if (!current) {
return false;
}
do {
if (current.nodeType === 1) {
if (testElementFunction(current)) {
return current;
}
// do not traverse upwards past the nearest containing editor
if (Util.isMediumEditorElement(current)) {
return false;
}
}
current = current.parentNode;
} while (current);
return false;
},
htmlEntities: function (str) {
// converts special characters (like <) into their escaped/encoded values (like <).
// This allows you to show to display the string without the browser reading it as HTML.
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
},
// http://stackoverflow.com/questions/6690752/insert-html-at-caret-in-a-contenteditable-div
insertHTMLCommand: function (doc, html) {
var selection, range, el, fragment, node, lastNode, toReplace,
res = false,
ecArgs = ['insertHTML', false, html];
/* Edge's implementation of insertHTML is just buggy right now:
* - Doesn't allow leading white space at the beginning of an element
* - Found a case when a <font size="2"> tag was inserted when calling alignCenter inside a blockquote
*
* There are likely other bugs, these are just the ones we found so far.
* For now, let's just use the same fallback we did for IE
*/
if (!MediumEditor.util.isEdge && doc.queryCommandSupported('insertHTML')) {
try {
return doc.execCommand.apply(doc, ecArgs);
} catch (ignore) {}
}
selection = doc.getSelection();
if (selection.rangeCount) {
range = selection.getRangeAt(0);
toReplace = range.commonAncestorContainer;
// https://github.com/yabwe/medium-editor/issues/748
// If the selection is an empty editor element, create a temporary text node inside of the editor
// and select it so that we don't delete the editor element
if (Util.isMediumEditorElement(toReplace) && !toReplace.firstChild) {
range.selectNode(toReplace.appendChild(doc.createTextNode('')));
} else if ((toReplace.nodeType === 3 && range.startOffset === 0 && range.endOffset === toReplace.nodeValue.length) ||
(toReplace.nodeType !== 3 && toReplace.innerHTML === range.toString())) {
// Ensure range covers maximum amount of nodes as possible
// By moving up the DOM and selecting ancestors whose only child is the range
while (!Util.isMediumEditorElement(toReplace) &&
toReplace.parentNode &&
toReplace.parentNode.childNodes.length === 1 &&
!Util.isMediumEditorElement(toReplace.parentNode)) {
toReplace = toReplace.parentNode;
}
range.selectNode(toReplace);
}
range.deleteContents();
el = doc.createElement('div');
el.innerHTML = html;
fragment = doc.createDocumentFragment();
while (el.firstChild) {
node = el.firstChild;
lastNode = fragment.appendChild(node);
}
range.insertNode(fragment);
// Preserve the selection:
if (lastNode) {
range = range.cloneRange();
range.setStartAfter(lastNode);
range.collapse(true);
MediumEditor.selection.selectRange(doc, range);
}
res = true;
}
// https://github.com/yabwe/medium-editor/issues/992
// If we're monitoring calls to execCommand, notify listeners as if a real call had happened
if (doc.execCommand.callListeners) {
doc.execCommand.callListeners(ecArgs, res);
}
return res;
},
execFormatBlock: function (doc, tagName) {
// Get the top level block element that contains the selection
var blockContainer = Util.getTopBlockContainer(MediumEditor.selection.getSelectionStart(doc)),
childNodes;
// Special handling for blockquote
if (tagName === 'blockquote') {
if (blockContainer) {
childNodes = Array.prototype.slice.call(blockContainer.childNodes);
// Check if the blockquote has a block element as a child (nested blocks)
if (childNodes.some(function (childNode) {
return Util.isBlockContainer(childNode);
})) {
// FF handles blockquote differently on formatBlock
// allowing nesting, we need to use outdent
// https://developer.mozilla.org/en-US/docs/Rich-Text_Editing_in_Mozilla
return doc.execCommand('outdent', false, null);
}
}
// When IE blockquote needs to be called as indent
// http://stackoverflow.com/questions/1816223/rich-text-editor-with-blockquote-function/1821777#1821777
if (Util.isIE) {
return doc.execCommand('indent', false, tagName);
}
}
// If the blockContainer is already the element type being passed in
// treat it as 'undo' formatting and just convert it to a <p>
if (blockContainer && tagName === blockContainer.nodeName.toLowerCase()) {
tagName = 'p';
}
// When IE we need to add <> to heading elements
// http://stackoverflow.com/questions/10741831/execcommand-formatblock-headings-in-ie
if (Util.isIE) {
tagName = '<' + tagName + '>';
}
// When FF, IE and Edge, we have to handle blockquote node seperately as 'formatblock' does not work.
// https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand#Commands
if (blockContainer && blockContainer.nodeName.toLowerCase() === 'blockquote') {
// For IE, just use outdent
if (Util.isIE && tagName === '<p>') {
return doc.execCommand('outdent', false, tagName);
}
// For Firefox and Edge, make sure there's a nested block element before calling outdent
if ((Util.isFF || Util.isEdge) && tagName === 'p') {
childNodes = Array.prototype.slice.call(blockContainer.childNodes);
// If there are some non-block elements we need to wrap everything in a <p> before we outdent
if (childNodes.some(function (childNode) {
return !Util.isBlockContainer(childNode);
})) {
doc.execCommand('formatBlock', false, tagName);
}
return doc.execCommand('outdent', false, tagName);
}
}
return doc.execCommand('formatBlock', false, tagName);
},
/**
* Set target to blank on the given el element
*
* TODO: not sure if this should be here
*
* When creating a link (using core -> createLink) the selection returned by Firefox will be the parent of the created link
* instead of the created link itself (as it is for Chrome for example), so we retrieve all "a" children to grab the good one by
* using `anchorUrl` to ensure that we are adding target="_blank" on the good one.
* This isn't a bulletproof solution anyway ..
*/
setTargetBlank: function (el, anchorUrl) {
var i, url = anchorUrl || false;
if (el.nodeName.toLowerCase() === 'a') {
el.target = '_blank';
el.rel = 'noopener noreferrer';
} else {
el = el.getElementsByTagName('a');
for (i = 0; i < el.length; i += 1) {
if (false === url || url === el[i].attributes.href.value) {
el[i].target = '_blank';
el[i].rel = 'noopener noreferrer';
}
}
}
},
/*
* this function is called to explicitly remove the target='_blank' as FF holds on to _blank value even
* after unchecking the checkbox on anchor form
*/
removeTargetBlank: function (el, anchorUrl) {
var i;
if (el.nodeName.toLowerCase() === 'a') {
el.removeAttribute('target');
el.removeAttribute('rel');
} else {
el = el.getElementsByTagName('a');
for (i = 0; i < el.length; i += 1) {
if (anchorUrl === el[i].attributes.href.value) {
el[i].removeAttribute('target');
el[i].removeAttribute('rel');
}
}
}
},
/*
* this function adds one or several classes on an a element.
* if el parameter is not an a, it will look for a children of el.
* if no a children are found, it will look for the a parent.
*/
addClassToAnchors: function (el, buttonClass) {
var classes = buttonClass.split(' '),
i,
j;
if (el.nodeName.toLowerCase() === 'a') {
for (j = 0; j < classes.length; j += 1) {
el.classList.add(classes[j]);
}
} else {
var aChildren = el.getElementsByTagName('a');
if (aChildren.length === 0) {
var parentAnchor = Util.getClosestTag(el, 'a');
el = parentAnchor ? [parentAnchor] : [];
} else {
el = aChildren;
}
for (i = 0; i < el.length; i += 1) {
for (j = 0; j < classes.length; j += 1) {
el[i].classList.add(classes[j]);
}
}
}
},
isListItem: function (node) {
if (!node) {
return false;
}
if (node.nodeName.toLowerCase() === 'li') {
return true;
}
var parentNode = node.parentNode,
tagName = parentNode.nodeName.toLowerCase();
while (tagName === 'li' || (!Util.isBlockContainer(parentNode) && tagName !== 'div')) {
if (tagName === 'li') {
return true;
}
parentNode = parentNode.parentNode;
if (parentNode) {
tagName = parentNode.nodeName.toLowerCase();
} else {
return false;
}
}
return false;
},
cleanListDOM: function (ownerDocument, element) {
if (element.nodeName.toLowerCase() !== 'li') {
return;
}
var list = element.parentElement;
if (list.parentElement.nodeName.toLowerCase() === 'p') { // yes we need to clean up
Util.unwrap(list.parentElement, ownerDocument);
// move cursor at the end of the text inside the list
// for some unknown reason, the cursor is moved to end of the "visual" line
MediumEditor.selection.moveCursor(ownerDocument, element.firstChild, element.firstChild.textContent.length);
}
},
/* splitDOMTree
*
* Given a root element some descendant element, split the root element
* into its own element containing the descendant element and all elements
* on the left or right side of the descendant ('right' is default)
*
* example:
*
* <div>
* / | \
* <span> <span> <span>
* / \ / \ / \
* 1 2 3 4 5 6
*
* If I wanted to split this tree given the <div> as the root and "4" as the leaf
* the result would be (the prime ' marks indicates nodes that are created as clones):
*
* SPLITTING OFF 'RIGHT' TREE SPLITTING OFF 'LEFT' TREE
*
* <div> <div>' <div>' <div>
* / \ / \ / \ |
* <span> <span> <span>' <span> <span> <span> <span>
* / \ | | / \ /\ /\ /\
* 1 2 3 4 5 6 1 2 3 4 5 6
*
* The above example represents splitting off the 'right' or 'left' part of a tree, where
* the <div>' would be returned as an element not appended to the DOM, and the <div>
* would remain in place where it was
*
*/
splitOffDOMTree: function (rootNode, leafNode, splitLeft) {
var splitOnNode = leafNode,
createdNode = null,
splitRight = !splitLeft;
// loop until we hit the root
while (splitOnNode !== rootNode) {
var currParent = splitOnNode.parentNode,
newParent = currParent.cloneNode(false),
targetNode = (splitRight ? splitOnNode : currParent.firstChild),
appendLast;
// Create a new parent element which is a clone of the current parent
if (createdNode) {
if (splitRight) {
// If we're splitting right, add previous created element before siblings
newParent.appendChild(createdNode);
} else {
// If we're splitting left, add previous created element last
appendLast = createdNode;
}
}
createdNode = newParent;
while (targetNode) {
var sibling = targetNode.nextSibling;
// Special handling for the 'splitNode'
if (targetNode === splitOnNode) {
if (!targetNode.hasChildNodes()) {
targetNode.parentNode.removeChild(targetNode);
} else {
// For the node we're splitting on, if it has children, we need to clone it
// and not just move it
targetNode = targetNode.cloneNode(false);
}
// If the resulting split node has content, add it
if (targetNode.textContent) {
createdNode.appendChild(targetNode);
}
targetNode = (splitRight ? sibling : null);
} else {
// For general case, just remove the element and only
// add it to the split tree if it contains something
targetNode.parentNode.removeChild(targetNode);
if (targetNode.hasChildNodes() || targetNode.textContent) {
createdNode.appendChild(targetNode);
}
targetNode = sibling;
}
}
// If we had an element we wanted to append at the end, do that now
if (appendLast) {
createdNode.appendChild(appendLast);
}
splitOnNode = currParent;
}
return createdNode;
},
moveTextRangeIntoElement: function (startNode, endNode, newElement) {
if (!startNode || !endNode) {
return false;
}
var rootNode = Util.findCommonRoot(startNode, endNode);
if (!rootNode) {
return false;
}
if (endNode === startNode) {
var temp = startNode.parentNode,
sibling = startNode.nextSibling;
temp.removeChild(startNode);
newElement.appendChild(startNode);
if (sibling) {
temp.insertBefore(newElement, sibling);
} else {
temp.appendChild(newElement);
}
return newElement.hasChildNodes();
}
// create rootChildren array which includes all the children
// we care about
var rootChildren = [],
firstChild,
lastChild,
nextNode;
for (var i = 0; i < rootNode.childNodes.length; i++) {
nextNode = rootNode.childNodes[i];
if (!firstChild) {
if (Util.isDescendant(nextNode, startNode, true)) {
firstChild = nextNode;
}
} else {
if (Util.isDescendant(nextNode, endNode, true)) {
lastChild = nextNode;
break;
} else {
rootChildren.push(nextNode);
}
}
}
var afterLast = lastChild.nextSibling,
fragment = rootNode.ownerDocument.createDocumentFragment();
// build up fragment on startNode side of tree
if (firstChild === startNode) {
firstChild.parentNode.removeChild(firstChild);
fragment.appendChild(firstChild);
} else {
fragment.appendChild(Util.splitOffDOMTree(firstChild, startNode));
}
// add any elements between firstChild & lastChild
rootChildren.forEach(function (element) {
element.parentNode.removeChild(element);
fragment.appendChild(element);
});
// build up fragment on endNode side of the tree
if (lastChild === endNode) {
lastChild.parentNode.removeChild(lastChild);
fragment.appendChild(lastChild);
} else {
fragment.appendChild(Util.splitOffDOMTree(lastChild, endNode, true));
}
// Add fragment into passed in element
newElement.appendChild(fragment);
if (lastChild.parentNode === rootNode) {
// If last child is in the root, insert newElement in front of it
rootNode.insertBefore(newElement, lastChild);
} else if (afterLast) {
// If last child was removed, but it had a sibling, insert in front of it
rootNode.insertBefore(newElement, afterLast);
} else {
// lastChild was removed and was the last actual element just append
rootNode.appendChild(newElement);
}
return newElement.hasChildNodes();
},
/* based on http://stackoverflow.com/a/6183069 */
depthOfNode: function (inNode) {
var theDepth = 0,
node = inNode;
while (node.parentNode !== null) {
node = node.parentNode;
theDepth++;
}
return theDepth;
},
findCommonRoot: function (inNode1, inNode2) {
var depth1 = Util.depthOfNode(inNode1),
depth2 = Util.depthOfNode(inNode2),
node1 = inNode1,
node2 = inNode2;
while (depth1 !== depth2) {
if (depth1 > depth2) {
node1 = node1.parentNode;
depth1 -= 1;
} else {
node2 = node2.parentNode;
depth2 -= 1;
}
}
while (node1 !== node2) {
node1 = node1.parentNode;
node2 = node2.parentNode;
}
return node1;
},
/* END - based on http://stackoverflow.com/a/6183069 */
isElementAtBeginningOfBlock: function (node) {
var textVal,
sibling;
while (!Util.isBlockContainer(node) && !Util.isMediumEditorElement(node)) {
sibling = node;
while (sibling = sibling.previousSibling) {
textVal = sibling.nodeType === 3 ? sibling.nodeValue : sibling.textContent;
if (textVal.length > 0) {
return false;
}
}
node = node.parentNode;
}
return true;
},
isMediumEditorElement: function (element) {
return element && element.getAttribute && !!element.getAttribute('data-medium-editor-element');
},
getContainerEditorElement: function (element) {
return Util.traverseUp(element, function (node) {
return Util.isMediumEditorElement(node);
});
},
isBlockContainer: function (element) {
return element && element.nodeType !== 3 && Util.blockContainerElementNames.indexOf(element.nodeName.toLowerCase()) !== -1;
},
/* Finds the closest ancestor which is a block container element
* If element is within editor element but not within any other block element,
* the editor element is returned
*/
getClosestBlockContainer: function (node) {
return Util.traverseUp(node, function (node) {
return Util.isBlockContainer(node) || Util.isMediumEditorElement(node);
});
},
/* Finds highest level ancestor element which is a block container element
* If element is within editor element but not within any other block element,
* the editor element is returned
*/
getTopBlockContainer: function (element) {
var topBlock = Util.isBlockContainer(element) ? element : false;
Util.traverseUp(element, function (el) {
if (Util.isBlockContainer(el)) {
topBlock = el;
}
if (!topBlock && Util.isMediumEditorElement(el)) {
topBlock = el;
return true;
}
return false;
});
return topBlock;
},
getFirstSelectableLeafNode: function (element) {
while (element && element.firstChild) {
element = element.firstChild;
}
// We don't want to set the selection to an element that can't have children, this messes up Gecko.
element = Util.traverseUp(element, function (el) {
return Util.emptyElementNames.indexOf(el.nodeName.toLowerCase()) === -1;
});
// Selecting at the beginning of a table doesn't work in PhantomJS.
if (element.nodeName.toLowerCase() === 'table') {
var firstCell = element.querySelector('th, td');
if (firstCell) {
element = firstCell;
}
}
return element;
},
// TODO: remove getFirstTextNode AND _getFirstTextNode when jumping in 6.0.0 (no code references)
getFirstTextNode: function (element) {
Util.warn('getFirstTextNode is deprecated and will be removed in version 6.0.0');
return Util._getFirstTextNode(element);
},
_getFirstTextNode: function (element) {
if (element.nodeType === 3) {
return element;
}
for (var i = 0; i < element.childNodes.length; i++) {
var textNode = Util._getFirstTextNode(element.childNodes[i]);
if (textNode !== null) {
return textNode;
}
}
return null;
},
ensureUrlHasProtocol: function (url) {
if (url.indexOf('://') === -1) {
return 'http://' + url;
}
return url;
},
warn: function () {
if (window.console !== undefined && typeof window.console.warn === 'function') {
window.console.warn.apply(window.console, arguments);
}
},
deprecated: function (oldName, newName, version) {
// simple deprecation warning mechanism.
var m = oldName + ' is deprecated, please use ' + newName + ' instead.';
if (version) {
m += ' Will be removed in ' + version;
}
Util.warn(m);
},
deprecatedMethod: function (oldName, newName, args, version) {
// run the replacement and warn when someone calls a deprecated method
Util.deprecated(oldName, newName, version);
if (typeof this[newName] === 'function') {
this[newName].apply(this, args);
}
},
cleanupAttrs: function (el, attrs) {
attrs.forEach(function (attr) {
el.removeAttribute(attr);
});
},
cleanupTags: function (el, tags) {
if (tags.indexOf(el.nodeName.toLowerCase()) !== -1) {
el.parentNode.removeChild(el);
}
},
unwrapTags: function (el, tags) {
if (tags.indexOf(el.nodeName.toLowerCase()) !== -1) {
MediumEditor.util.unwrap(el, document);
}
},
// get the closest parent
getClosestTag: function (el, tag) {
return Util.traverseUp(el, function (element) {
return element.nodeName.toLowerCase() === tag.toLowerCase();
});
},
unwrap: function (el, doc) {
var fragment = doc.createDocumentFragment(),
nodes = Array.prototype.slice.call(el.childNodes);
// cast nodeList to array since appending child
// to a different node will alter length of el.childNodes
for (var i = 0; i < nodes.length; i++) {
fragment.appendChild(nodes[i]);
}
if (fragment.childNodes.length) {
el.parentNode.replaceChild(fragment, el);
} else {
el.parentNode.removeChild(el);
}
},
guid: function () {
function _s4() {
return Math
.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
return _s4() + _s4() + '-' + _s4() + '-' + _s4() + '-' + _s4() + '-' + _s4() + _s4() + _s4();
}
};
MediumEditor.util = Util;
}(window));