UNPKG

medium-editor

Version:
678 lines (589 loc) 29.7 kB
(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; }());