medium-editor
Version:
Medium.com WYSIWYG editor clone.
678 lines (589 loc) • 29.7 kB
JavaScript
(function () {
'use strict';
function filterOnlyParentElements(node) {
if (MediumEditor.util.isBlockContainer(node)) {
return NodeFilter.FILTER_ACCEPT;
} else {
return NodeFilter.FILTER_SKIP;
}
}
var Selection = {
findMatchingSelectionParent: function (testElementFunction, contentWindow) {
var selection = contentWindow.getSelection(),
range,
current;
if (selection.rangeCount === 0) {
return false;
}
range = selection.getRangeAt(0);
current = range.commonAncestorContainer;
return MediumEditor.util.traverseUp(current, testElementFunction);
},
getSelectionElement: function (contentWindow) {
return this.findMatchingSelectionParent(function (el) {
return MediumEditor.util.isMediumEditorElement(el);
}, contentWindow);
},
// http://stackoverflow.com/questions/17678843/cant-restore-selection-after-html-modify-even-if-its-the-same-html
// Tim Down
exportSelection: function (root, doc) {
if (!root) {
return null;
}
var selectionState = null,
selection = doc.getSelection();
if (selection.rangeCount > 0) {
var range = selection.getRangeAt(0),
preSelectionRange = range.cloneRange(),
start;
preSelectionRange.selectNodeContents(root);
preSelectionRange.setEnd(range.startContainer, range.startOffset);
start = preSelectionRange.toString().length;
selectionState = {
start: start,
end: start + range.toString().length
};
// Check to see if the selection starts with any images
// if so we need to make sure the the beginning of the selection is
// set correctly when importing selection
if (this.doesRangeStartWithImages(range, doc)) {
selectionState.startsWithImage = true;
}
// Check to see if the selection has any trailing images
// if so, this this means we need to look for them when we import selection
var trailingImageCount = this.getTrailingImageCount(root, selectionState, range.endContainer, range.endOffset);
if (trailingImageCount) {
selectionState.trailingImageCount = trailingImageCount;
}
// If start = 0 there may still be an empty paragraph before it, but we don't care.
if (start !== 0) {
var emptyBlocksIndex = this.getIndexRelativeToAdjacentEmptyBlocks(doc, root, range.startContainer, range.startOffset);
if (emptyBlocksIndex !== -1) {
selectionState.emptyBlocksIndex = emptyBlocksIndex;
}
}
}
return selectionState;
},
// http://stackoverflow.com/questions/17678843/cant-restore-selection-after-html-modify-even-if-its-the-same-html
// Tim Down
//
// {object} selectionState - the selection to import
// {DOMElement} root - the root element the selection is being restored inside of
// {Document} doc - the document to use for managing selection
// {boolean} [favorLaterSelectionAnchor] - defaults to false. If true, import the cursor immediately
// subsequent to an anchor tag if it would otherwise be placed right at the trailing edge inside the
// anchor. This cursor positioning, even though visually equivalent to the user, can affect behavior
// in MS IE.
importSelection: function (selectionState, root, doc, favorLaterSelectionAnchor) {
if (!selectionState || !root) {
return;
}
var range = doc.createRange();
range.setStart(root, 0);
range.collapse(true);
var node = root,
nodeStack = [],
charIndex = 0,
foundStart = false,
foundEnd = false,
trailingImageCount = 0,
stop = false,
nextCharIndex,
allowRangeToStartAtEndOfNode = false,
lastTextNode = null;
// When importing selection, the start of the selection may lie at the end of an element
// or at the beginning of an element. Since visually there is no difference between these 2
// we will try to move the selection to the beginning of an element since this is generally
// what users will expect and it's a more predictable behavior.
//
// However, there are some specific cases when we don't want to do this:
// 1) We're attempting to move the cursor outside of the end of an anchor [favorLaterSelectionAnchor = true]
// 2) The selection starts with an image, which is special since an image doesn't have any 'content'
// as far as selection and ranges are concerned
// 3) The selection starts after a specified number of empty block elements (selectionState.emptyBlocksIndex)
//
// For these cases, we want the selection to start at a very specific location, so we should NOT
// automatically move the cursor to the beginning of the first actual chunk of text
if (favorLaterSelectionAnchor || selectionState.startsWithImage || typeof selectionState.emptyBlocksIndex !== 'undefined') {
allowRangeToStartAtEndOfNode = true;
}
while (!stop && node) {
// Only iterate over elements and text nodes
if (node.nodeType > 3) {
node = nodeStack.pop();
continue;
}
// If we hit a text node, we need to add the amount of characters to the overall count
if (node.nodeType === 3 && !foundEnd) {
nextCharIndex = charIndex + node.length;
// Check if we're at or beyond the start of the selection we're importing
if (!foundStart && selectionState.start >= charIndex && selectionState.start <= nextCharIndex) {
// NOTE: We only want to allow a selection to start at the END of an element if
// allowRangeToStartAtEndOfNode is true
if (allowRangeToStartAtEndOfNode || selectionState.start < nextCharIndex) {
range.setStart(node, selectionState.start - charIndex);
foundStart = true;
}
// We're at the end of a text node where the selection could start but we shouldn't
// make the selection start here because allowRangeToStartAtEndOfNode is false.
// However, we should keep a reference to this node in case there aren't any more
// text nodes after this, so that we have somewhere to import the selection to
else {
lastTextNode = node;
}
}
// We've found the start of the selection, check if we're at or beyond the end of the selection we're importing
if (foundStart && selectionState.end >= charIndex && selectionState.end <= nextCharIndex) {
if (!selectionState.trailingImageCount) {
range.setEnd(node, selectionState.end - charIndex);
stop = true;
} else {
foundEnd = true;
}
}
charIndex = nextCharIndex;
} else {
if (selectionState.trailingImageCount && foundEnd) {
if (node.nodeName.toLowerCase() === 'img') {
trailingImageCount++;
}
if (trailingImageCount === selectionState.trailingImageCount) {
// Find which index the image is in its parent's children
var endIndex = 0;
while (node.parentNode.childNodes[endIndex] !== node) {
endIndex++;
}
range.setEnd(node.parentNode, endIndex + 1);
stop = true;
}
}
if (!stop && node.nodeType === 1) {
// this is an element
// add all its children to the stack
var i = node.childNodes.length - 1;
while (i >= 0) {
nodeStack.push(node.childNodes[i]);
i -= 1;
}
}
}
if (!stop) {
node = nodeStack.pop();
}
}
// If we've gone through the entire text but didn't find the beginning of a text node
// to make the selection start at, we should fall back to starting the selection
// at the END of the last text node we found
if (!foundStart && lastTextNode) {
range.setStart(lastTextNode, lastTextNode.length);
range.setEnd(lastTextNode, lastTextNode.length);
}
if (typeof selectionState.emptyBlocksIndex !== 'undefined') {
range = this.importSelectionMoveCursorPastBlocks(doc, root, selectionState.emptyBlocksIndex, range);
}
// If the selection is right at the ending edge of a link, put it outside the anchor tag instead of inside.
if (favorLaterSelectionAnchor) {
range = this.importSelectionMoveCursorPastAnchor(selectionState, range);
}
this.selectRange(doc, range);
},
// Utility method called from importSelection only
importSelectionMoveCursorPastAnchor: function (selectionState, range) {
var nodeInsideAnchorTagFunction = function (node) {
return node.nodeName.toLowerCase() === 'a';
};
if (selectionState.start === selectionState.end &&
range.startContainer.nodeType === 3 &&
range.startOffset === range.startContainer.nodeValue.length &&
MediumEditor.util.traverseUp(range.startContainer, nodeInsideAnchorTagFunction)) {
var prevNode = range.startContainer,
currentNode = range.startContainer.parentNode;
while (currentNode !== null && currentNode.nodeName.toLowerCase() !== 'a') {
if (currentNode.childNodes[currentNode.childNodes.length - 1] !== prevNode) {
currentNode = null;
} else {
prevNode = currentNode;
currentNode = currentNode.parentNode;
}
}
if (currentNode !== null && currentNode.nodeName.toLowerCase() === 'a') {
var currentNodeIndex = null;
for (var i = 0; currentNodeIndex === null && i < currentNode.parentNode.childNodes.length; i++) {
if (currentNode.parentNode.childNodes[i] === currentNode) {
currentNodeIndex = i;
}
}
range.setStart(currentNode.parentNode, currentNodeIndex + 1);
range.collapse(true);
}
}
return range;
},
// Uses the emptyBlocksIndex calculated by getIndexRelativeToAdjacentEmptyBlocks
// to move the cursor back to the start of the correct paragraph
importSelectionMoveCursorPastBlocks: function (doc, root, index, range) {
var treeWalker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, filterOnlyParentElements, false),
startContainer = range.startContainer,
startBlock,
targetNode,
currIndex = 0;
index = index || 1; // If index is 0, we still want to move to the next block
// Chrome counts newlines and spaces that separate block elements as actual elements.
// If the selection is inside one of these text nodes, and it has a previous sibling
// which is a block element, we want the treewalker to start at the previous sibling
// and NOT at the parent of the textnode
if (startContainer.nodeType === 3 && MediumEditor.util.isBlockContainer(startContainer.previousSibling)) {
startBlock = startContainer.previousSibling;
} else {
startBlock = MediumEditor.util.getClosestBlockContainer(startContainer);
}
// Skip over empty blocks until we hit the block we want the selection to be in
while (treeWalker.nextNode()) {
if (!targetNode) {
// Loop through all blocks until we hit the starting block element
if (startBlock === treeWalker.currentNode) {
targetNode = treeWalker.currentNode;
}
} else {
targetNode = treeWalker.currentNode;
currIndex++;
// We hit the target index, bail
if (currIndex === index) {
break;
}
// If we find a non-empty block, ignore the emptyBlocksIndex and just put selection here
if (targetNode.textContent.length > 0) {
break;
}
}
}
if (!targetNode) {
targetNode = startBlock;
}
// We're selecting a high-level block node, so make sure the cursor gets moved into the deepest
// element at the beginning of the block
range.setStart(MediumEditor.util.getFirstSelectableLeafNode(targetNode), 0);
return range;
},
// Returns -1 unless the cursor is at the beginning of a paragraph/block
// If the paragraph/block is preceeded by empty paragraphs/block (with no text)
// it will return the number of empty paragraphs before the cursor.
// Otherwise, it will return 0, which indicates the cursor is at the beginning
// of a paragraph/block, and not at the end of the paragraph/block before it
getIndexRelativeToAdjacentEmptyBlocks: function (doc, root, cursorContainer, cursorOffset) {
// If there is text in front of the cursor, that means there isn't only empty blocks before it
if (cursorContainer.textContent.length > 0 && cursorOffset > 0) {
return -1;
}
// Check if the block that contains the cursor has any other text in front of the cursor
var node = cursorContainer;
if (node.nodeType !== 3) {
node = cursorContainer.childNodes[cursorOffset];
}
if (node) {
// The element isn't at the beginning of a block, so it has content before it
if (!MediumEditor.util.isElementAtBeginningOfBlock(node)) {
return -1;
}
var previousSibling = MediumEditor.util.findPreviousSibling(node);
// If there is no previous sibling, this is the first text element in the editor
if (!previousSibling) {
return -1;
}
// If the previous sibling has text, then there are no empty blocks before this
else if (previousSibling.nodeValue) {
return -1;
}
}
// Walk over block elements, counting number of empty blocks between last piece of text
// and the block the cursor is in
var closestBlock = MediumEditor.util.getClosestBlockContainer(cursorContainer),
treeWalker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, filterOnlyParentElements, false),
emptyBlocksCount = 0;
while (treeWalker.nextNode()) {
var blockIsEmpty = treeWalker.currentNode.textContent === '';
if (blockIsEmpty || emptyBlocksCount > 0) {
emptyBlocksCount += 1;
}
if (treeWalker.currentNode === closestBlock) {
return emptyBlocksCount;
}
if (!blockIsEmpty) {
emptyBlocksCount = 0;
}
}
return emptyBlocksCount;
},
// Returns true if the selection range begins with an image tag
// Returns false if the range starts with any non empty text nodes
doesRangeStartWithImages: function (range, doc) {
if (range.startOffset !== 0 || range.startContainer.nodeType !== 1) {
return false;
}
if (range.startContainer.nodeName.toLowerCase() === 'img') {
return true;
}
var img = range.startContainer.querySelector('img');
if (!img) {
return false;
}
var treeWalker = doc.createTreeWalker(range.startContainer, NodeFilter.SHOW_ALL, null, false);
while (treeWalker.nextNode()) {
var next = treeWalker.currentNode;
// If we hit the image, then there isn't any text before the image so
// the image is at the beginning of the range
if (next === img) {
break;
}
// If we haven't hit the iamge, but found text that contains content
// then the range doesn't start with an image
if (next.nodeValue) {
return false;
}
}
return true;
},
getTrailingImageCount: function (root, selectionState, endContainer, endOffset) {
// If the endOffset of a range is 0, the endContainer doesn't contain images
// If the endContainer is a text node, there are no trailing images
if (endOffset === 0 || endContainer.nodeType !== 1) {
return 0;
}
// If the endContainer isn't an image, and doesn't have an image descendants
// there are no trailing images
if (endContainer.nodeName.toLowerCase() !== 'img' && !endContainer.querySelector('img')) {
return 0;
}
var lastNode = endContainer.childNodes[endOffset - 1];
while (lastNode.hasChildNodes()) {
lastNode = lastNode.lastChild;
}
var node = root,
nodeStack = [],
charIndex = 0,
foundStart = false,
foundEnd = false,
stop = false,
nextCharIndex,
trailingImages = 0;
while (!stop && node) {
// Only iterate over elements and text nodes
if (node.nodeType > 3) {
node = nodeStack.pop();
continue;
}
if (node.nodeType === 3 && !foundEnd) {
trailingImages = 0;
nextCharIndex = charIndex + node.length;
if (!foundStart && selectionState.start >= charIndex && selectionState.start <= nextCharIndex) {
foundStart = true;
}
if (foundStart && selectionState.end >= charIndex && selectionState.end <= nextCharIndex) {
foundEnd = true;
}
charIndex = nextCharIndex;
} else {
if (node.nodeName.toLowerCase() === 'img') {
trailingImages++;
}
if (node === lastNode) {
stop = true;
} else if (node.nodeType === 1) {
// this is an element
// add all its children to the stack
var i = node.childNodes.length - 1;
while (i >= 0) {
nodeStack.push(node.childNodes[i]);
i -= 1;
}
}
}
if (!stop) {
node = nodeStack.pop();
}
}
return trailingImages;
},
// determine if the current selection contains any 'content'
// content being any non-white space text or an image
selectionContainsContent: function (doc) {
var sel = doc.getSelection();
// collapsed selection or selection withour range doesn't contain content
if (!sel || sel.isCollapsed || !sel.rangeCount) {
return false;
}
// if toString() contains any text, the selection contains some content
if (sel.toString().trim() !== '') {
return true;
}
// if selection contains only image(s), it will return empty for toString()
// so check for an image manually
var selectionNode = this.getSelectedParentElement(sel.getRangeAt(0));
if (selectionNode) {
if (selectionNode.nodeName.toLowerCase() === 'img' ||
(selectionNode.nodeType === 1 && selectionNode.querySelector('img'))) {
return true;
}
}
return false;
},
selectionInContentEditableFalse: function (contentWindow) {
// determine if the current selection is exclusively inside
// a contenteditable="false", though treat the case of an
// explicit contenteditable="true" inside a "false" as false.
var sawtrue,
sawfalse = this.findMatchingSelectionParent(function (el) {
var ce = el && el.getAttribute('contenteditable');
if (ce === 'true') {
sawtrue = true;
}
return el.nodeName !== '#text' && ce === 'false';
}, contentWindow);
return !sawtrue && sawfalse;
},
// http://stackoverflow.com/questions/4176923/html-of-selected-text
// by Tim Down
getSelectionHtml: function getSelectionHtml(doc) {
var i,
html = '',
sel = doc.getSelection(),
len,
container;
if (sel.rangeCount) {
container = doc.createElement('div');
for (i = 0, len = sel.rangeCount; i < len; i += 1) {
container.appendChild(sel.getRangeAt(i).cloneContents());
}
html = container.innerHTML;
}
return html;
},
/**
* Find the caret position within an element irrespective of any inline tags it may contain.
*
* @param {DOMElement} An element containing the cursor to find offsets relative to.
* @param {Range} A Range representing cursor position. Will window.getSelection if none is passed.
* @return {Object} 'left' and 'right' attributes contain offsets from begining and end of Element
*/
getCaretOffsets: function getCaretOffsets(element, range) {
var preCaretRange, postCaretRange;
if (!range) {
range = window.getSelection().getRangeAt(0);
}
preCaretRange = range.cloneRange();
postCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(range.endContainer, range.endOffset);
postCaretRange.selectNodeContents(element);
postCaretRange.setStart(range.endContainer, range.endOffset);
return {
left: preCaretRange.toString().length,
right: postCaretRange.toString().length
};
},
// http://stackoverflow.com/questions/15867542/range-object-get-selection-parent-node-chrome-vs-firefox
rangeSelectsSingleNode: function (range) {
var startNode = range.startContainer;
return startNode === range.endContainer &&
startNode.hasChildNodes() &&
range.endOffset === range.startOffset + 1;
},
getSelectedParentElement: function (range) {
if (!range) {
return null;
}
// Selection encompasses a single element
if (this.rangeSelectsSingleNode(range) && range.startContainer.childNodes[range.startOffset].nodeType !== 3) {
return range.startContainer.childNodes[range.startOffset];
}
// Selection range starts inside a text node, so get its parent
if (range.startContainer.nodeType === 3) {
return range.startContainer.parentNode;
}
// Selection starts inside an element
return range.startContainer;
},
getSelectedElements: function (doc) {
var selection = doc.getSelection(),
range,
toRet,
currNode;
if (!selection.rangeCount || selection.isCollapsed || !selection.getRangeAt(0).commonAncestorContainer) {
return [];
}
range = selection.getRangeAt(0);
if (range.commonAncestorContainer.nodeType === 3) {
toRet = [];
currNode = range.commonAncestorContainer;
while (currNode.parentNode && currNode.parentNode.childNodes.length === 1) {
toRet.push(currNode.parentNode);
currNode = currNode.parentNode;
}
return toRet;
}
return [].filter.call(range.commonAncestorContainer.getElementsByTagName('*'), function (el) {
return (typeof selection.containsNode === 'function') ? selection.containsNode(el, true) : true;
});
},
selectNode: function (node, doc) {
var range = doc.createRange();
range.selectNodeContents(node);
this.selectRange(doc, range);
},
select: function (doc, startNode, startOffset, endNode, endOffset) {
var range = doc.createRange();
range.setStart(startNode, startOffset);
if (endNode) {
range.setEnd(endNode, endOffset);
} else {
range.collapse(true);
}
this.selectRange(doc, range);
return range;
},
/**
* Clear the current highlighted selection and set the caret to the start or the end of that prior selection, defaults to end.
*
* @param {DomDocument} doc Current document
* @param {boolean} moveCursorToStart A boolean representing whether or not to set the caret to the beginning of the prior selection.
*/
clearSelection: function (doc, moveCursorToStart) {
if (moveCursorToStart) {
doc.getSelection().collapseToStart();
} else {
doc.getSelection().collapseToEnd();
}
},
/**
* Move cursor to the given node with the given offset.
*
* @param {DomDocument} doc Current document
* @param {DomElement} node Element where to jump
* @param {integer} offset Where in the element should we jump, 0 by default
*/
moveCursor: function (doc, node, offset) {
this.select(doc, node, offset);
},
getSelectionRange: function (ownerDocument) {
var selection = ownerDocument.getSelection();
if (selection.rangeCount === 0) {
return null;
}
return selection.getRangeAt(0);
},
selectRange: function (ownerDocument, range) {
var selection = ownerDocument.getSelection();
selection.removeAllRanges();
selection.addRange(range);
},
// http://stackoverflow.com/questions/1197401/how-can-i-get-the-element-the-caret-is-in-with-javascript-when-using-contentedi
// by You
getSelectionStart: function (ownerDocument) {
var node = ownerDocument.getSelection().anchorNode,
startNode = (node && node.nodeType === 3 ? node.parentNode : node);
return startNode;
}
};
MediumEditor.selection = Selection;
}());