diff --git a/lib/wysihtml/rails/version.rb b/lib/wysihtml/rails/version.rb index 4f59571..dc79025 100644 --- a/lib/wysihtml/rails/version.rb +++ b/lib/wysihtml/rails/version.rb @@ -1,5 +1,5 @@ module Wysihtml module Rails - VERSION = "0.5.1" + VERSION = "0.5.2" end end diff --git a/vendor/assets/javascripts/wysihtml-toolbar.js b/vendor/assets/javascripts/wysihtml-toolbar.js index e8c5f61..6906a42 100644 --- a/vendor/assets/javascripts/wysihtml-toolbar.js +++ b/vendor/assets/javascripts/wysihtml-toolbar.js @@ -1,5 +1,5 @@ /** - * @license wysihtml v0.5.1 + * @license wysihtml v0.5.2 * https://github.com/Voog/wysihtml * * Author: Christopher Blum (https://github.com/tiff) @@ -10,7 +10,7 @@ * */ var wysihtml5 = { - version: "0.5.1", + version: "0.5.2", // namespaces commands: {}, @@ -76,19 +76,19 @@ var wysihtml5 = { // element.textContent polyfill. if (Object.defineProperty && Object.getOwnPropertyDescriptor && Object.getOwnPropertyDescriptor(win.Element.prototype, "textContent") && !Object.getOwnPropertyDescriptor(win.Element.prototype, "textContent").get) { - (function() { - var innerText = Object.getOwnPropertyDescriptor(win.Element.prototype, "innerText"); - Object.defineProperty(win.Element.prototype, "textContent", - { - get: function() { - return innerText.get.call(this); - }, - set: function(s) { - return innerText.set.call(this, s); - } - } - ); - })(); + (function() { + var innerText = Object.getOwnPropertyDescriptor(win.Element.prototype, "innerText"); + Object.defineProperty(win.Element.prototype, "textContent", + { + get: function() { + return innerText.get.call(this); + }, + set: function(s) { + return innerText.set.call(this, s); + } + } + ); + })(); } // isArray polyfill for ie8 @@ -133,20 +133,36 @@ var wysihtml5 = { }; } - // Element.matches Adds ie8 support and unifies nonstandard function names in other browsers - win.Element && function(ElementPrototype) { - ElementPrototype.matches = ElementPrototype.matches || - ElementPrototype.matchesSelector || - ElementPrototype.mozMatchesSelector || - ElementPrototype.msMatchesSelector || - ElementPrototype.oMatchesSelector || - ElementPrototype.webkitMatchesSelector || - function (selector) { - var node = this, nodes = (node.parentNode || node.document).querySelectorAll(selector), i = -1; - while (nodes[++i] && nodes[i] != node); - return !!nodes[i]; + // closest and matches polyfill + // https://github.com/jonathantneal/closest + (function (ELEMENT) { + ELEMENT.matches = ELEMENT.matches || ELEMENT.mozMatchesSelector || ELEMENT.msMatchesSelector || ELEMENT.oMatchesSelector || ELEMENT.webkitMatchesSelector || function matches(selector) { + var + element = this, + elements = (element.document || element.ownerDocument).querySelectorAll(selector), + index = 0; + + while (elements[index] && elements[index] !== element) { + ++index; + } + + return elements[index] ? true : false; }; - }(win.Element.prototype); + + ELEMENT.closest = ELEMENT.closest || function closest(selector) { + var element = this; + + while (element) { + if (element.matches(selector)) { + break; + } + + element = element.parentElement; + } + + return element; + }; + }(Element.prototype)); // Element.classList for ie8-9 (toggle all IE) // source http://purl.eligrey.com/github/classList.js/blob/master/classList.js @@ -4335,7 +4351,8 @@ wysihtml5.polyfills(window, document); } return api; -}, this);;/** +}, this); +;/** * Text range module for Rangy. * Text-based manipulation and searching of ranges and selections. * @@ -7869,6 +7886,11 @@ wysihtml5.dom.copyAttributes = function(attributesToCopy) { return nodes; } + // Returns if node is the rangy selection bookmark element (that must not be taken into account in most situatons and is removed on selection restoring) + function isBookmark(n) { + return n && n.nodeType === 1 && n.classList.contains('rangySelectionBoundary'); + } + wysihtml5.dom.domNode = function(node) { var defaultNodeTypes = [wysihtml5.ELEMENT_NODE, wysihtml5.TEXT_NODE]; @@ -7902,6 +7924,7 @@ wysihtml5.dom.copyAttributes = function(attributesToCopy) { } if ( + isBookmark(prevNode) || // is Rangy temporary boomark element (bypass) (!wysihtml5.lang.array(types).contains(prevNode.nodeType)) || // nodeTypes check. (options && options.ignoreBlankTexts && wysihtml5.dom.domNode(prevNode).is.emptyTextNode(true)) // Blank text nodes bypassed if set ) { @@ -7921,6 +7944,7 @@ wysihtml5.dom.copyAttributes = function(attributesToCopy) { } if ( + isBookmark(nextNode) || // is Rangy temporary boomark element (bypass) (!wysihtml5.lang.array(types).contains(nextNode.nodeType)) || // nodeTypes check. (options && options.ignoreBlankTexts && wysihtml5.dom.domNode(nextNode).is.emptyTextNode(true)) // blank text nodes bypassed if set ) { @@ -8082,7 +8106,7 @@ wysihtml5.dom.copyAttributes = function(attributesToCopy) { } } - if (properties.nodeName && node.nodeName !== properties.nodeName) { + if (properties.nodeName && node.nodeName.toLowerCase() !== properties.nodeName.toLowerCase()) { return false; } @@ -12532,15 +12556,40 @@ wysihtml5.quirks.ensureProperClearing = (function() { * Select line where the caret is in */ selectLine: function() { + var r = rangy.createRange(); if (wysihtml5.browser.supportsSelectionModify()) { this._selectLine_W3C(); - } else if (this.doc.selection) { - this._selectLine_MSIE(); - } else { - // For IE Edge as it ditched the old api and did not fully implement the new one (as expected) - this._selectLineUniversal(); + } else if (r.nativeRange && r.nativeRange.getBoundingClientRect) { + // For IE Edge as it ditched the old api and did not fully implement the new one (as expected)*/ + this._selectLineUniversal(); } }, + + includeRangyRangeHelpers: function() { + var s = this.getSelection(), + r = s.getRangeAt(0), + isHelperNode = function(node) { + return (node && node.nodeType === 1 && node.classList.contains('rangySelectionBoundary')); + }, + getNodeLength = function (node) { + if (node.nodeType === 1) { + return node.childNodes && node.childNodes.length || 0; + } else { + return node.data && node.data.length || 0; + } + // body... + }, + anode = s.anchorNode.nodeType === 1 ? s.anchorNode.childNodes[s.anchorOffset] : s.anchorNode, + fnode = s.focusNode.nodeType === 1 ? s.focusNode.childNodes[s.focusOffset] : s.focusNode; + + if (fnode && s.focusOffset === getNodeLength(fnode) && fnode.nextSibling && isHelperNode(fnode.nextSibling)) { + r.setEndAfter(fnode.nextSibling); + } + if (anode && s.anchorOffset === 0 && anode.previousSibling && isHelperNode(anode.previousSibling)) { + r.setStartBefore(anode.previousSibling); + } + r.select(); + }, /** * See https://developer.mozilla.org/en/DOM/Selection/modify @@ -12559,6 +12608,8 @@ wysihtml5.quirks.ensureProperClearing = (function() { selection.focusOffset === initialBoundry[3] ) { this._selectLineUniversal(); + } else { + this.includeRangyRangeHelpers(); } }, @@ -12610,19 +12661,45 @@ wysihtml5.quirks.ensureProperClearing = (function() { rect, startRange, endRange, testRange, count = 0, - amount, testRect, found; + amount, testRect, found, + that = this, + isLineBreakingElement = function(el) { + return el && el.nodeType === 1 && (that.win.getComputedStyle(el).display === "block" || wysihtml5.lang.array(['BR', 'HR']).contains(el.nodeName)); + }, + prevNode = function(node) { + var pnode = node; + if (pnode) { + while (pnode && ((pnode.nodeType === 1 && pnode.classList.contains('rangySelectionBoundary')) || (pnode.nodeType === 3 && (/^\s*$/).test(pnode.data)))) { + pnode = pnode.previousSibling; + } + } + return pnode; + }; startRange = r.cloneRange(); endRange = r.cloneRange(); if (r.collapsed) { - r.expand('word', 1); - rect = r.nativeRange.getBoundingClientRect(); + // Collapsed state can not have a bounding rect. Thus need to expand it at least by 1 character first while not crossing line boundary + // TODO: figure out a shorter and more readable way + if (r.startContainer.nodeType === 3 && r.startOffset < r.startContainer.data.length) { + r.moveEnd('character', 1); + } else if (r.startContainer.nodeType === 1 && r.startContainer.childNodes[r.startOffset] && r.startContainer.childNodes[r.startOffset].nodeType === 3 && r.startContainer.childNodes[r.startOffset].data.length > 0) { + r.moveEnd('character', 1); + } else if (r.startOffset > 0 && ( r.startContainer.nodeType === 3 || (r.startContainer.nodeType === 1 && !isLineBreakingElement(prevNode(r.startContainer.childNodes[r.startOffset - 1]))))) { + r.moveStart('character', -1); + } } - + if (!r.collapsed) { + r.insertNode(this.doc.createTextNode(wysihtml5.INVISIBLE_SPACE)); + } + + // Is probably just empty line as can not be expanded + rect = r.nativeRange.getBoundingClientRect(); do { amount = r.moveStart('character', -1); testRect = r.nativeRange.getBoundingClientRect(); + if (!testRect || Math.floor(testRect.top) !== Math.floor(rect.top)) { r.moveStart('character', 1); found = true; @@ -12638,61 +12715,24 @@ wysihtml5.quirks.ensureProperClearing = (function() { testRect = r.nativeRange.getBoundingClientRect(); if (!testRect || Math.floor(testRect.bottom) !== Math.floor(rect.bottom)) { r.moveEnd('character', -1); + + // Fix a IE line end marked by linebreak element although caret is before it + // If causes problems should be changed to be applied only to IE + if (r.endContainer && r.endContainer.nodeType === 1 && r.endContainer.childNodes[r.endOffset] && r.endContainer.childNodes[r.endOffset].nodeType === 1 && r.endContainer.childNodes[r.endOffset].nodeName === "BR" && r.endContainer.childNodes[r.endOffset].previousSibling) { + if (r.endContainer.childNodes[r.endOffset].previousSibling.nodeType === 1) { + r.setEnd(r.endContainer.childNodes[r.endOffset].previousSibling, r.endContainer.childNodes[r.endOffset].previousSibling.childNodes.length); + } else if (r.endContainer.childNodes[r.endOffset].previousSibling.nodeType === 3) { + r.setEnd(r.endContainer.childNodes[r.endOffset].previousSibling, r.endContainer.childNodes[r.endOffset].previousSibling.data.length); + } + } + found = true; } count++; } while (amount !== 0 && !found && count < 2000); r.select(); - }, - - _selectLine_MSIE: function() { - var range = this.doc.selection && this.doc.selection.createRange ? this.doc.selection.createRange() : this.doc.createRange(), - rangeTop = range.boundingTop, - scrollWidth = this.doc.body.scrollWidth, - rangeBottom, - rangeEnd, - measureNode, - i, - j; - - window.r = range; - - if (!range.moveToPoint) { - return; - } - - if (rangeTop === 0) { - // Don't know why, but when the selection ends at the end of a line - // range.boundingTop is 0 - measureNode = this.doc.createElement("span"); - this.insertNode(measureNode); - rangeTop = measureNode.offsetTop; - measureNode.parentNode.removeChild(measureNode); - } - - rangeTop += 1; - - for (i=-10; i=0; j--) { - try { - rangeEnd.moveToPoint(j, rangeBottom); - break; - } catch(e2) {} - } - - range.setEndPoint("EndToEnd", rangeEnd); - range.select(); + this.includeRangyRangeHelpers(); }, getText: function() { @@ -13993,18 +14033,56 @@ wysihtml5.Commands = Base.extend( }; } + function getRangeNode(node, offset) { + if (node.nodeType === 3) { + return node; + } else { + return node.childNodes[offset] || node; + } + } + + // Returns if node is a line break + function isBr(n) { + return n && n.nodeType === 1 && n.nodeName === "BR"; + } + + // Is block level element + function isBlock(n, composer) { + return n && n.nodeType === 1 && composer.win.getComputedStyle(n).display === "block"; + } + + // Returns if node is the rangy selection bookmark element (that must not be taken into account in most situatons and is removed on selection restoring) + function isBookmark(n) { + return n && n.nodeType === 1 && n.classList.contains('rangySelectionBoundary'); + } + + // Is line breaking node + function isLineBreaking(n, composer) { + return isBr(n) || isBlock(n, composer); + } + // Removes empty block level elements - function cleanup(composer) { + function cleanup(composer, newBlockElements) { + wysihtml5.dom.removeInvisibleSpaces(composer.element); var container = composer.element, allElements = container.querySelectorAll(BLOCK_ELEMENTS), - uneditables = container.querySelectorAll(composer.config.classNames.uneditableContainer), - elements = wysihtml5.lang.array(allElements).without(uneditables); + noEditQuery = composer.config.classNames.uneditableContainer + ([""]).concat(BLOCK_ELEMENTS.split(',')).join(", " + composer.config.classNames.uneditableContainer + ' '), + uneditables = container.querySelectorAll(noEditQuery), + elements = wysihtml5.lang.array(allElements).without(uneditables), // Lets not touch uneditable elements and their contents + nbIdx; for (var i = elements.length; i--;) { if (elements[i].innerHTML.replace(/[\uFEFF]/g, '') === "") { + // If cleanup removes some new block elements. remove them from newblocks array too + nbIdx = wysihtml5.lang.array(newBlockElements).indexOf(elements[i]); + if (nbIdx > -1) { + newBlockElements.splice(nbIdx, 1); + } elements[i].parentNode.removeChild(elements[i]); } } + + return newBlockElements; } function defaultNodeName(composer) { @@ -14026,13 +14104,15 @@ wysihtml5.Commands = Base.extend( return block; } + // Clone for splitting the inner inline element out of its parent inline elements context + // For example if selection is in bold and italic, clone the outer nodes and wrap these around content and return function cloneOuterInlines(node, container) { var n = node, innerNode, parentNode, el = null, el2; - + while (n && container && n !== container) { if (n.nodeType === 1 && n.matches(INLINE_ELEMENTS)) { parentNode = n; @@ -14088,7 +14168,10 @@ wysihtml5.Commands = Base.extend( // Unsets element properties by options // If nodename given and matches current element, element is unwrapped or converted to default node (depending on presence of class and style attributes) function removeOptionsFromElement(element, options, composer) { - var style, classes; + var style, classes, + prevNode = element.previousSibling, + nextNode = element.nextSibling, + unwrapped = false; if (options.styleProperty) { element.style[wysihtml5.browser.fixStyleKey(options.styleProperty)] = ''; @@ -14106,10 +14189,11 @@ wysihtml5.Commands = Base.extend( element.removeAttribute('class'); } - if (options.nodeName && element.nodeName === options.nodeName) { + if (options.nodeName && element.nodeName.toLowerCase() === options.nodeName.toLowerCase()) { style = element.getAttribute('style'); if (!style || style.trim() === '') { dom.unwrap(element); + unwrapped = true; } else { element = dom.renameElement(element, defaultNodeName(composer)); } @@ -14119,60 +14203,79 @@ wysihtml5.Commands = Base.extend( if (element.getAttribute('style') !== null && element.getAttribute('style').trim() === "") { element.removeAttribute('style'); } + + if (unwrapped) { + applySurroundingLineBreaks(prevNode, nextNode, composer); + } } // Unwraps block level elements from inside content // Useful as not all block level elements can contain other block-levels function unwrapBlocksFromContent(element) { - var contentBlocks = element.querySelectorAll(BLOCK_ELEMENTS) || []; // Find unnestable block elements in extracted contents + var blocks = element.querySelectorAll(BLOCK_ELEMENTS) || [], // Find unnestable block elements in extracted contents + nextEl, prevEl; - for (var i = contentBlocks.length; i--;) { - if (!contentBlocks[i].nextSibling || contentBlocks[i].nextSibling.nodeType !== 1 || contentBlocks[i].nextSibling.nodeName !== 'BR') { - if ((contentBlocks[i].innerHTML || contentBlocks[i].nodeValue || '').trim() !== '') { - contentBlocks[i].parentNode.insertBefore(contentBlocks[i].ownerDocument.createElement('BR'), contentBlocks[i].nextSibling); + for (var i = blocks.length; i--;) { + nextEl = wysihtml5.dom.domNode(blocks[i]).next({nodeTypes: [1,3], ignoreBlankTexts: true}), + prevEl = wysihtml5.dom.domNode(blocks[i]).prev({nodeTypes: [1,3], ignoreBlankTexts: true}); + + if (nextEl && nextEl.nodeType !== 1 && nextEl.nodeName !== 'BR') { + if ((blocks[i].innerHTML || blocks[i].nodeValue || '').trim() !== '') { + blocks[i].parentNode.insertBefore(blocks[i].ownerDocument.createElement('BR'), nextEl); } } - wysihtml5.dom.unwrap(contentBlocks[i]); + if (nextEl && nextEl.nodeType !== 1 && nextEl.nodeName !== 'BR') { + if ((blocks[i].innerHTML || blocks[i].nodeValue || '').trim() !== '') { + blocks[i].parentNode.insertBefore(blocks[i].ownerDocument.createElement('BR'), nextEl); + } + } + wysihtml5.dom.unwrap(blocks[i]); } } // Fix ranges that visually cover whole block element to actually cover the block function fixRangeCoverage(range, composer) { - var node; + var node, + start = range.startContainer, + end = range.endContainer; - if (range.startContainer && range.startContainer.nodeType === 1 && range.startContainer === range.endContainer) { - if (range.startContainer.firstChild === range.startContainer.lastChild && range.endOffset === 1) { - if (range.startContainer !== composer.element) { - range.setStartBefore(range.startContainer); - range.setEndAfter(range.endContainer); + // If range has only one childNode and it is end to end the range, extend the range to contain the container element too + // This ensures the wrapper node is modified and optios added to it + if (start && start.nodeType === 1 && start === end) { + if (start.firstChild === start.lastChild && range.endOffset === 1) { + if (start !== composer.element && start.nodeName !== 'LI' && start.nodeName !== 'TD') { + range.setStartBefore(start); + range.setEndAfter(end); } } return; } - if (range.startContainer && range.startContainer.nodeType === 1 && range.endContainer.nodeType === 3) { - if (range.startContainer.firstChild === range.endContainer && range.endOffset === 1) { - if (range.startContainer !== composer.element) { - range.setEndAfter(range.startContainer); + // If range starts outside of node and ends inside at textrange and covers the whole node visually, extend end to cover the node end too + if (start && start.nodeType === 1 && end.nodeType === 3) { + if (start.firstChild === end && range.endOffset === end.data.length) { + if (start !== composer.element && start.nodeName !== 'LI' && start.nodeName !== 'TD') { + range.setEndAfter(start); } } return; } - - if (range.endContainer && range.endContainer.nodeType === 1 && range.startContainer.nodeType === 3) { - if (range.endContainer.firstChild === range.startContainer && range.endOffset === 1) { - if (range.endContainer !== composer.element) { - range.setStartBefore(range.endContainer); + + // If range ends outside of node and starts inside at textrange and covers the whole node visually, extend start to cover the node start too + if (end && end.nodeType === 1 && start.nodeType === 3) { + if (end.firstChild === start && range.startOffset === 0) { + if (end !== composer.element && end.nodeName !== 'LI' && end.nodeName !== 'TD') { + range.setStartBefore(end); } } return; } - - if (range.startContainer && range.startContainer.nodeType === 3 && range.startContainer === range.endContainer && range.startContainer.parentNode) { - if (range.startContainer.parentNode.firstChild === range.startContainer && range.endOffset == range.endContainer.length && range.startOffset === 0) { - node = range.startContainer.parentNode; - if (node !== composer.element) { + // If range covers a whole textnode and the textnode is the only child of node, extend range to node + if (start && start.nodeType === 3 && start === end && start.parentNode.childNodes.length === 1) { + if (range.endOffset == end.data.length && range.startOffset === 0) { + node = start.parentNode; + if (node !== composer.element && node.nodeName !== 'LI' && node.nodeName !== 'TD') { range.setStartBefore(node); range.setEndAfter(node); } @@ -14180,108 +14283,285 @@ wysihtml5.Commands = Base.extend( return; } } + + // Scans ranges array for insertion points that are not allowed to insert block tags fixes/splits illegal ranges + // Some places do not allow block level elements inbetween (inside ul and outside li) + // TODO: might need extending for other nodes besides li (maybe dd,dl,dt) + function fixNotPermittedInsertionPoints(ranges) { + var newRanges = [], + lis, j, maxj, tmpRange, rangePos, closestLI; + + for (var i = 0, maxi = ranges.length; i < maxi; i++) { + + // Fixes range start and end positions if inside UL or OL element (outside of LI) + if (ranges[i].startContainer.nodeType === 1 && ranges[i].startContainer.matches('ul, ol')) { + ranges[i].setStart(ranges[i].startContainer.childNodes[ranges[i].startOffset], 0); + } + if (ranges[i].endContainer.nodeType === 1 && ranges[i].endContainer.matches('ul, ol')) { + closestLI = ranges[i].endContainer.childNodes[Math.max(ranges[i].endOffset - 1, 0)]; + if (closestLI.childNodes) { + ranges[i].setEnd(closestLI, closestLI.childNodes.length); + } + } - // Wrap the range with a block level element - // If element is one of unnestable block elements (ex: h2 inside h1), split nodes and insert between so nesting does not occur - function wrapRangeWithElement(range, options, defaultName, composer) { - var defaultOptions = (options) ? wysihtml5.lang.object(options).clone(true) : null; - if (defaultOptions) { - defaultOptions.nodeName = defaultOptions.nodeName || defaultName || defaultNodeName(composer); + // Get all LI eleemnts in selection (fully or partially covered) + // And make sure ranges are either inside LI or outside UL/OL + // Split and add new ranges as needed to cover same range content + // TODO: Needs improvement to accept DL, DD, DT + lis = ranges[i].getNodes([1], function(node) { + return node.nodeName === "LI"; + }); + if (lis.length > 0) { + + for (j = 0, maxj = lis.length; j < maxj; j++) { + rangePos = ranges[i].compareNode(lis[j]); + + // Fixes start of range that crosses LI border + if (rangePos === ranges[i].NODE_AFTER || rangePos === ranges[i].NODE_INSIDE) { + // Range starts before and ends inside the node + + tmpRange = ranges[i].cloneRange(); + closestLI = wysihtml5.dom.domNode(lis[j]).prev({nodeTypes: [1]}); + + if (closestLI) { + tmpRange.setEnd(closestLI, closestLI.childNodes.length); + } else if (lis[j].closest('ul, ol')) { + tmpRange.setEndBefore(lis[j].closest('ul, ol')); + } else { + tmpRange.setEndBefore(lis[j]); + } + newRanges.push(tmpRange); + ranges[i].setStart(lis[j], 0); + } + + // Fixes end of range that crosses li border + if (rangePos === ranges[i].NODE_BEFORE || rangePos === ranges[i].NODE_INSIDE) { + // Range starts inside the node and ends after node + + tmpRange = ranges[i].cloneRange(); + tmpRange.setEnd(lis[j], lis[j].childNodes.length); + newRanges.push(tmpRange); + + // Find next LI in list and if present set range to it, else + closestLI = wysihtml5.dom.domNode(lis[j]).next({nodeTypes: [1]}); + if (closestLI) { + ranges[i].setStart(closestLI, 0); + } else if (lis[j].closest('ul, ol')) { + ranges[i].setStartAfter(lis[j].closest('ul, ol')); + } else { + ranges[i].setStartAfter(lis[j]); + } + } + } + newRanges.push(ranges[i]); + } else { + newRanges.push(ranges[i]); + } } - fixRangeCoverage(range, composer); + return newRanges; + } + + // Return options object with nodeName set if original did not have any + // Node name is set to local or global default + function getOptionsWithNodename(options, defaultName, composer) { + var correctedOptions = (options) ? wysihtml5.lang.object(options).clone(true) : null; + if (correctedOptions) { + correctedOptions.nodeName = correctedOptions.nodeName || defaultName || defaultNodeName(composer); + } + return correctedOptions; + } + + // Injects document fragment to range ensuring outer elements are split to a place where block elements are allowed to be inserted + // Also wraps empty clones of split parent tags around fragment to keep formatting + // If firstOuterBlock is given assume that instead of finding outer (useful for solving cases of some blocks are allowed into others while others are not) + function injectFragmentToRange(fragment, range, composer, firstOuterBlock) { + var rangeStartContainer = range.startContainer, + firstOuterBlock = firstOuterBlock || findOuterBlock(rangeStartContainer, composer.element, true), + outerInlines, first, last, prev, next; + + if (firstOuterBlock) { + // If selection starts inside un-nestable block, split-escape the unnestable point and insert node between + first = fragment.firstChild; + last = fragment.lastChild; + + composer.selection.splitElementAtCaret(firstOuterBlock, fragment); + + next = wysihtml5.dom.domNode(last).next({nodeTypes: [1,3], ignoreBlankTexts: true}); + prev = wysihtml5.dom.domNode(first).prev({nodeTypes: [1,3], ignoreBlankTexts: true}); + if (first && !isLineBreaking(first, composer) && prev && !isLineBreaking(prev, composer)) { + first.parentNode.insertBefore(composer.doc.createElement('br'), first); + } + + if (last && !isLineBreaking(last, composer) && next && !isLineBreaking(next, composer)) { + next.parentNode.insertBefore(composer.doc.createElement('br'), next); + } + + } else { + // Ensure node does not get inserted into an inline where it is not allowed + outerInlines = cloneOuterInlines(rangeStartContainer, composer.element); + if (outerInlines.outerNode && outerInlines.innerNode && outerInlines.parent) { + if (fragment.childNodes.length === 1) { + while(fragment.firstChild.firstChild) { + outerInlines.innerNode.appendChild(fragment.firstChild.firstChild); + } + fragment.firstChild.appendChild(outerInlines.outerNode); + } + composer.selection.splitElementAtCaret(outerInlines.parent, fragment); + } else { + // Otherwise just insert + range.insertNode(fragment); + } + } + } + + // Removes all block formatting from range + function clearRangeBlockFromating(range, closestBlockName, composer) { var r = range.cloneRange(), + prevNode = getRangeNode(r.startContainer, r.startOffset).previousSibling, + nextNode = getRangeNode(r.endContainer, r.endOffset).nextSibling, + content = r.extractContents(), + fragment = composer.doc.createDocumentFragment(), + children, blocks, + first = true; + + while(content.firstChild) { + // Iterate over all selection content first level childNodes + if (content.firstChild.nodeType === 1 && content.firstChild.matches(BLOCK_ELEMENTS)) { + // If node is a block element + // Split block formating and add new block to wrap caret + + unwrapBlocksFromContent(content.firstChild); + children = wysihtml5.dom.unwrap(content.firstChild); + + // Add line break before if needed + if (children.length > 0) { + if ( + (fragment.lastChild && (fragment.lastChild.nodeType !== 1 || !isLineBreaking(fragment.lastChild, composer))) || + (!fragment.lastChild && prevNode && (prevNode.nodeType !== 1 || isLineBreaking(prevNode, composer))) + ){ + fragment.appendChild(composer.doc.createElement('BR')); + } + } + + for (var c = 0, cmax = children.length; c < cmax; c++) { + fragment.appendChild(children[c]); + } + + // Add line break after if needed + if (children.length > 0) { + if (fragment.lastChild.nodeType !== 1 || !isLineBreaking(fragment.lastChild, composer)) { + if (nextNode || fragment.lastChild !== content.lastChild) { + fragment.appendChild(composer.doc.createElement('BR')); + } + } + } + + } else { + fragment.appendChild(content.firstChild); + } + + first = false; + } + blocks = wysihtml5.lang.array(fragment.childNodes).get(); + injectFragmentToRange(fragment, r, composer); + return blocks; + } + + // When block node is inserted, look surrounding nodes and remove surplous linebreak tags (as block format breaks line itself) + function removeSurroundingLineBreaks(prevNode, nextNode, composer) { + var prevPrev = prevNode && wysihtml5.dom.domNode(prevNode).prev({nodeTypes: [1,3], ignoreBlankTexts: true}); + if (isBr(nextNode)) { + nextNode.parentNode.removeChild(nextNode); + } + if (isBr(prevNode) && (!prevPrev || prevPrev.nodeType !== 1 || composer.win.getComputedStyle(prevPrev).display !== "block")) { + prevNode.parentNode.removeChild(prevNode); + } + } + + function applySurroundingLineBreaks(prevNode, nextNode, composer) { + var prevPrev; + + if (prevNode && isBookmark(prevNode)) { + prevNode = prevNode.previousSibling; + } + if (nextNode && isBookmark(nextNode)) { + nextNode = nextNode.nextSibling; + } + + prevPrev = prevNode && prevNode.previousSibling; + + if (prevNode && (prevNode.nodeType !== 1 || (composer.win.getComputedStyle(prevNode).display !== "block" && !isBr(prevNode))) && prevNode.parentNode) { + prevNode.parentNode.insertBefore(composer.doc.createElement('br'), prevNode.nextSibling); + } + + if (nextNode && (nextNode.nodeType !== 1 || composer.win.getComputedStyle(nextNode).display !== "block") && nextNode.parentNode) { + nextNode.parentNode.insertBefore(composer.doc.createElement('br'), nextNode); + } + } + + // Wrap the range with a block level element + // If element is one of unnestable block elements (ex: h2 inside h1), split nodes and insert between so nesting does not occur + function wrapRangeWithElement(range, options, closestBlockName, composer) { + var similarOptions = options ? correctOptionsForSimilarityCheck(options) : null, + r = range.cloneRange(), rangeStartContainer = r.startContainer, + prevNode = wysihtml5.dom.domNode(getRangeNode(r.startContainer, r.startOffset)).prev({nodeTypes: [1,3], ignoreBlankTexts: true}), + nextNode = wysihtml5.dom.domNode(getRangeNode(r.endContainer, r.endOffset)).next({nodeTypes: [1,3], ignoreBlankTexts: true}), content = r.extractContents(), fragment = composer.doc.createDocumentFragment(), - similarOptions = defaultOptions ? correctOptionsForSimilarityCheck(defaultOptions) : null, similarOuterBlock = similarOptions ? wysihtml5.dom.getParentElement(rangeStartContainer, similarOptions, null, composer.element) : null, - splitAllBlocks = !defaultOptions || (defaultName === "BLOCKQUOTE" && defaultOptions.nodeName && defaultOptions.nodeName === "BLOCKQUOTE"), + splitAllBlocks = !closestBlockName || !options || (options.nodeName === "BLOCKQUOTE" && closestBlockName === "BLOCKQUOTE"), firstOuterBlock = similarOuterBlock || findOuterBlock(rangeStartContainer, composer.element, splitAllBlocks), // The outermost un-nestable block element parent of selection start wrapper, blocks, children; - if (options && options.nodeName && options.nodeName === "BLOCKQUOTE") { + if (options && options.nodeName === "BLOCKQUOTE") { + + // If blockquote is to be inserted no quessing just add it as outermost block on line or selection var tmpEl = applyOptionsToElement(null, options, composer); tmpEl.appendChild(content); fragment.appendChild(tmpEl); blocks = [tmpEl]; + } else { if (!content.firstChild) { + // IF selection is caret (can happen if line is empty) add format around tag fragment.appendChild(applyOptionsToElement(null, options, composer)); } else { while(content.firstChild) { + // Iterate over all selection content first level childNodes if (content.firstChild.nodeType == 1 && content.firstChild.matches(BLOCK_ELEMENTS)) { - if (options) { - // Escape(split) block formatting at caret - applyOptionsToElement(content.firstChild, options, composer); - if (content.firstChild.matches(UNNESTABLE_BLOCK_ELEMENTS)) { - unwrapBlocksFromContent(content.firstChild); - } - fragment.appendChild(content.firstChild); - - } else { - // Split block formating and add new block to wrap caret + // If node is a block element + // Escape(split) block formatting at caret + applyOptionsToElement(content.firstChild, options, composer); + if (content.firstChild.matches(UNNESTABLE_BLOCK_ELEMENTS)) { unwrapBlocksFromContent(content.firstChild); - children = wysihtml5.dom.unwrap(content.firstChild); - for (var c = 0, cmax = children.length; c < cmax; c++) { - fragment.appendChild(children[c]); - } - - if (fragment.childNodes.length > 0) { - fragment.appendChild(composer.doc.createElement('BR')); - } } + fragment.appendChild(content.firstChild); + } else { - - if (options) { - // Wrap subsequent non-block nodes inside new block element - wrapper = applyOptionsToElement(null, defaultOptions, composer); - while(content.firstChild && (content.firstChild.nodeType !== 1 || !content.firstChild.matches(BLOCK_ELEMENTS))) { - if (content.firstChild.nodeType == 1 && wrapper.matches(UNNESTABLE_BLOCK_ELEMENTS)) { - unwrapBlocksFromContent(content.firstChild); - } - wrapper.appendChild(content.firstChild); - } - fragment.appendChild(wrapper); - } else { - // Escape(split) block formatting at selection - if (content.firstChild.nodeType == 1) { + // Wrap subsequent non-block nodes inside new block element + wrapper = applyOptionsToElement(null, getOptionsWithNodename(options, closestBlockName, composer), composer); + while(content.firstChild && (content.firstChild.nodeType !== 1 || !content.firstChild.matches(BLOCK_ELEMENTS))) { + if (content.firstChild.nodeType == 1 && wrapper.matches(UNNESTABLE_BLOCK_ELEMENTS)) { unwrapBlocksFromContent(content.firstChild); } - fragment.appendChild(content.firstChild); + wrapper.appendChild(content.firstChild); } - + fragment.appendChild(wrapper); } } } blocks = wysihtml5.lang.array(fragment.childNodes).get(); } - if (firstOuterBlock) { - // If selection starts inside un-nestable block, split-escape the unnestable point and insert node between - composer.selection.splitElementAtCaret(firstOuterBlock, fragment); - } else { - // Ensure node does not get inserted into an inline where it is not allowed - var outerInlines = cloneOuterInlines(rangeStartContainer, composer.element); - if (outerInlines.outerNode && outerInlines.innerNode && outerInlines.parent) { - if (fragment.childNodes.length === 1) { - while(fragment.firstChild.firstChild) { - outerInlines.innerNode.appendChild(fragment.firstChild.firstChild); - } - fragment.firstChild.appendChild(outerInlines.outerNode); - } - composer.selection.splitElementAtCaret(outerInlines.parent, fragment); - } else { - // Otherwise just insert - r.insertNode(fragment); - } - } - + injectFragmentToRange(fragment, r, composer, firstOuterBlock); + removeSurroundingLineBreaks(prevNode, nextNode, composer); return blocks; } @@ -14293,101 +14573,154 @@ wysihtml5.Commands = Base.extend( return (parentNode) ? parentNode.nodeName : null; } + + // Expands caret to cover the closest block that: + // * cannot contain other block level elements (h1-6,p, etc) + // * Has the same nodeName that is to be inserted + // * has insertingNodeName + // * is DIV if insertingNodeName is not present + // + // If nothing found selects the current line + function expandCaretToBlock(composer, insertingNodeName) { + var parent = wysihtml5.dom.getParentElement(composer.selection.getOwnRanges()[0].startContainer, { + query: UNNESTABLE_BLOCK_ELEMENTS + ', ' + (insertingNodeName ? insertingNodeName.toLowerCase() : 'div'), + }, null, composer.element), + range; + + if (parent) { + range = composer.selection.createRange(); + range.selectNode(parent); + composer.selection.setSelection(range); + } else if (!composer.isEmpty()) { + composer.selection.selectLine(); + } + } + + // Set selection to begin inside first created block element (beginning of it) and end inside (and after content) of last block element + // TODO: Checking nodetype might be unnescescary as nodes inserted by formatBlock are nodetype 1 anyway + function selectElements(newBlockElements, composer) { + var range = composer.selection.createRange(), + lastEl = newBlockElements[newBlockElements.length - 1], + lastOffset = (lastEl.nodeType === 1 && lastEl.childNodes) ? lastEl.childNodes.length | 0 : lastEl.length || 0; + + range.setStart(newBlockElements[0], 0); + range.setEnd(lastEl, lastOffset); + range.select(); + } + + // Get all ranges from selection (takes out uneditables and out of editor parts) and apply format to each + // Return created/modified block level elements + // Method can be either "apply" or "remove" + function formatSelection(method, composer, options) { + var ranges = composer.selection.getOwnRanges(), + newBlockElements = [], + closestBlockName; + + // Some places do not allow block level elements inbetween (inside ul and outside li, inside table and outside of td/th) + ranges = fixNotPermittedInsertionPoints(ranges); + + for (var i = ranges.length; i--;) { + fixRangeCoverage(ranges[i], composer); + closestBlockName = getParentBlockNodeName(ranges[i].startContainer, composer); + if (method === "remove") { + newBlockElements = newBlockElements.concat(clearRangeBlockFromating(ranges[i], closestBlockName, composer)); + } else { + newBlockElements = newBlockElements.concat(wrapRangeWithElement(ranges[i], options, closestBlockName, composer)); + } + } + return newBlockElements; + } + + // If properties is passed as a string, look for tag with that tagName/query + function parseOptions(options) { + if (typeof options === "string") { + options = { + nodeName: options.toUpperCase() + }; + } + return options; + } wysihtml5.commands.formatBlock = { exec: function(composer, command, options) { + options = parseOptions(options); var newBlockElements = [], - placeholder, ranges, range, parent, bookmark, state; - - // If properties is passed as a string, look for tag with that tagName/query - if (typeof options === "string") { - options = { - nodeName: options.toUpperCase() - }; - } + ranges, range, bookmark, state, closestBlockName; - // Remove state if toggle set and state on and selection is collapsed + // Find if current format state is active if options.toggle is set as true + // In toggle case active state elemets are formatted instead of working directly on selection if (options && options.toggle) { state = this.state(composer, command, options); - if (state) { - bookmark = rangy.saveSelection(composer.win); - for (var j = 0, jmax = state.length; j < jmax; j++) { - removeOptionsFromElement(state[j], options, composer); - } - } } + if (state) { + // Remove format from state nodes if toggle set and state on and selection is collapsed + bookmark = rangy.saveSelection(composer.win); + for (var j = 0, jmax = state.length; j < jmax; j++) { + removeOptionsFromElement(state[j], options, composer); + } - // Otherwise expand selection so it will cover closest block if option caretSelectsBlock is true and selection is collapsed - if (!state) { - + } else { + // If selection is caret expand it to cover nearest suitable block element or row if none found if (composer.selection.isCollapsed()) { - parent = wysihtml5.dom.getParentElement(composer.selection.getOwnRanges()[0].startContainer, { - query: UNNESTABLE_BLOCK_ELEMENTS + ', ' + (options && options.nodeName ? options.nodeName.toLowerCase() : 'div'), - }, null, composer.element); - if (parent) { - bookmark = rangy.saveSelection(composer.win); - range = composer.selection.createRange(); - range.selectNode(parent); - composer.selection.setSelection(range); - } else if (!composer.isEmpty()) { - bookmark = rangy.saveSelection(composer.win); - composer.selection.selectLine(); - } + bookmark = rangy.saveSelection(composer.win); + expandCaretToBlock(composer, options && options.nodeName ? options.nodeName.toUpperCase() : undefined); } - - // And get all selection ranges of current composer and iterate - ranges = composer.selection.getOwnRanges(); - for (var i = ranges.length; i--;) { - newBlockElements = newBlockElements.concat(wrapRangeWithElement(ranges[i], options, getParentBlockNodeName(ranges[i].startContainer, composer), composer)); + if (options) { + newBlockElements = formatSelection("apply", composer, options); + } else { + // Options == null means block formatting should be removed from selection + newBlockElements = formatSelection("remove", composer); } - + } // Remove empty block elements that may be left behind - cleanup(composer); - // If cleanup removed some new block elements. remove them from array too - for (var e = newBlockElements.length; e--;) { - if (!newBlockElements[e].parentNode) { - newBlockElements.splice(e, 1); - } - } + // Also remove them from new blocks list + newBlockElements = cleanup(composer, newBlockElements); - // Restore correct selection + // Restore selection if (bookmark) { - wysihtml5.dom.removeInvisibleSpaces(composer.element); rangy.restoreSelection(bookmark); } else { - wysihtml5.dom.removeInvisibleSpaces(composer.element); - // Set selection to beging inside first created block element (beginning of it) and end inside (and after content) of last block element - // TODO: Checking nodetype might be unnescescary as nodes inserted by formatBlock are nodetype 1 anyway - range = composer.selection.createRange(); - range.setStart(newBlockElements[0], 0); - var lastEl = newBlockElements[newBlockElements.length - 1], - lastOffset = (lastEl.nodeType === 1 && lastEl.childNodes) ? lastEl.childNodes.length | 0 : lastEl.length || 0; - range.setEnd(lastEl, lastOffset); - range.select(); + selectElements(newBlockElements, composer); } }, - - // If properties as null is passed returns status describing all block level elements - state: function(composer, command, properties) { + + // Removes all block formatting from selection + remove: function(composer, command, options) { + options = parseOptions(options); + var newBlockElements, bookmark; - // If properties is passed as a string, look for tag with that tagName/query - if (typeof properties === "string") { - properties = { - query: properties - }; + // If selection is caret expand it to cover nearest suitable block element or row if none found + if (composer.selection.isCollapsed()) { + bookmark = rangy.saveSelection(composer.win); + expandCaretToBlock(composer, options && options.nodeName ? options.nodeName.toUpperCase() : undefined); } + + newBlockElements = formatSelection("remove", composer); + newBlockElements = cleanup(composer, newBlockElements); + + // Restore selection + if (bookmark) { + rangy.restoreSelection(bookmark); + } else { + selectElements(newBlockElements, composer); + } + }, + + // If options as null is passed returns status describing all block level elements + state: function(composer, command, options) { + options = parseOptions(options); var nodes = composer.selection.filterElements((function (element) { // Finds matching elements inside selection - return wysihtml5.dom.domNode(element).test(properties || { query: BLOCK_ELEMENTS }); + return wysihtml5.dom.domNode(element).test(options || { query: BLOCK_ELEMENTS }); }).bind(this)), parentNodes = composer.selection.getSelectedOwnNodes(), parent; // Finds matching elements that are parents of selection and adds to nodes list for (var i = 0, maxi = parentNodes.length; i < maxi; i++) { - parent = dom.getParentElement(parentNodes[i], properties || { query: BLOCK_ELEMENTS }, null, composer.element); + parent = dom.getParentElement(parentNodes[i], options || { query: BLOCK_ELEMENTS }, null, composer.element); if (parent && nodes.indexOf(parent) === -1) { nodes.push(parent); } @@ -14597,6 +14930,9 @@ wysihtml5.Commands = Base.extend( if (options.toggle !== false && element.classList.contains(options.className)) { element.classList.remove(options.className); } else { + if (options.classRegExp) { + element.className = element.className.replace(options.classRegExp, ''); + } element.classList.add(options.className); } if (hasNoClass(element)) { @@ -16247,7 +16583,7 @@ wysihtml5.views.View = Base.extend( cleanUp: function(rules) { var bookmark; - if (this.selection) { + if (this.selection && this.selection.isInThisEditable()) { bookmark = rangy.saveSelection(this.win); } this.parent.parse(this.element, undefined, rules); @@ -16419,6 +16755,8 @@ wysihtml5.views.View = Base.extend( ]).from(this.textarea.element).to(this.element); } + this._initAutoLinking(); + dom.addClass(this.element, this.config.classNames.composer); // // Make the editor look like the original textarea, by syncing styles @@ -16451,7 +16789,6 @@ wysihtml5.views.View = Base.extend( // Make sure that the browser avoids using inline styles whenever possible this.commands.exec("styleWithCSS", false); - this._initAutoLinking(); this._initObjectResizing(); this._initUndoManager(); this._initLineBreaking(); @@ -16485,10 +16822,7 @@ wysihtml5.views.View = Base.extend( supportsAutoLinking = browser.doesAutoLinkingInContentEditable(); if (supportsDisablingOfAutoLinking) { - // I have no idea why IE edge deletes element content here when calling the command, - var tmpHTML = this.element.innerHTML; this.commands.exec("AutoUrlDetect", false, false); - this.element.innerHTML = tmpHTML; } if (!this.config.autoLink) { diff --git a/vendor/assets/javascripts/wysihtml.js b/vendor/assets/javascripts/wysihtml.js index b14e8c5..b7a5ec0 100644 --- a/vendor/assets/javascripts/wysihtml.js +++ b/vendor/assets/javascripts/wysihtml.js @@ -1,5 +1,5 @@ /** - * @license wysihtml v0.5.1 + * @license wysihtml v0.5.2 * https://github.com/Voog/wysihtml * * Author: Christopher Blum (https://github.com/tiff) @@ -10,7 +10,7 @@ * */ var wysihtml5 = { - version: "0.5.1", + version: "0.5.2", // namespaces commands: {}, @@ -76,19 +76,19 @@ var wysihtml5 = { // element.textContent polyfill. if (Object.defineProperty && Object.getOwnPropertyDescriptor && Object.getOwnPropertyDescriptor(win.Element.prototype, "textContent") && !Object.getOwnPropertyDescriptor(win.Element.prototype, "textContent").get) { - (function() { - var innerText = Object.getOwnPropertyDescriptor(win.Element.prototype, "innerText"); - Object.defineProperty(win.Element.prototype, "textContent", - { - get: function() { - return innerText.get.call(this); - }, - set: function(s) { - return innerText.set.call(this, s); - } - } - ); - })(); + (function() { + var innerText = Object.getOwnPropertyDescriptor(win.Element.prototype, "innerText"); + Object.defineProperty(win.Element.prototype, "textContent", + { + get: function() { + return innerText.get.call(this); + }, + set: function(s) { + return innerText.set.call(this, s); + } + } + ); + })(); } // isArray polyfill for ie8 @@ -133,20 +133,36 @@ var wysihtml5 = { }; } - // Element.matches Adds ie8 support and unifies nonstandard function names in other browsers - win.Element && function(ElementPrototype) { - ElementPrototype.matches = ElementPrototype.matches || - ElementPrototype.matchesSelector || - ElementPrototype.mozMatchesSelector || - ElementPrototype.msMatchesSelector || - ElementPrototype.oMatchesSelector || - ElementPrototype.webkitMatchesSelector || - function (selector) { - var node = this, nodes = (node.parentNode || node.document).querySelectorAll(selector), i = -1; - while (nodes[++i] && nodes[i] != node); - return !!nodes[i]; + // closest and matches polyfill + // https://github.com/jonathantneal/closest + (function (ELEMENT) { + ELEMENT.matches = ELEMENT.matches || ELEMENT.mozMatchesSelector || ELEMENT.msMatchesSelector || ELEMENT.oMatchesSelector || ELEMENT.webkitMatchesSelector || function matches(selector) { + var + element = this, + elements = (element.document || element.ownerDocument).querySelectorAll(selector), + index = 0; + + while (elements[index] && elements[index] !== element) { + ++index; + } + + return elements[index] ? true : false; }; - }(win.Element.prototype); + + ELEMENT.closest = ELEMENT.closest || function closest(selector) { + var element = this; + + while (element) { + if (element.matches(selector)) { + break; + } + + element = element.parentElement; + } + + return element; + }; + }(Element.prototype)); // Element.classList for ie8-9 (toggle all IE) // source http://purl.eligrey.com/github/classList.js/blob/master/classList.js @@ -4335,7 +4351,8 @@ wysihtml5.polyfills(window, document); } return api; -}, this);;/** +}, this); +;/** * Text range module for Rangy. * Text-based manipulation and searching of ranges and selections. * @@ -7869,6 +7886,11 @@ wysihtml5.dom.copyAttributes = function(attributesToCopy) { return nodes; } + // Returns if node is the rangy selection bookmark element (that must not be taken into account in most situatons and is removed on selection restoring) + function isBookmark(n) { + return n && n.nodeType === 1 && n.classList.contains('rangySelectionBoundary'); + } + wysihtml5.dom.domNode = function(node) { var defaultNodeTypes = [wysihtml5.ELEMENT_NODE, wysihtml5.TEXT_NODE]; @@ -7902,6 +7924,7 @@ wysihtml5.dom.copyAttributes = function(attributesToCopy) { } if ( + isBookmark(prevNode) || // is Rangy temporary boomark element (bypass) (!wysihtml5.lang.array(types).contains(prevNode.nodeType)) || // nodeTypes check. (options && options.ignoreBlankTexts && wysihtml5.dom.domNode(prevNode).is.emptyTextNode(true)) // Blank text nodes bypassed if set ) { @@ -7921,6 +7944,7 @@ wysihtml5.dom.copyAttributes = function(attributesToCopy) { } if ( + isBookmark(nextNode) || // is Rangy temporary boomark element (bypass) (!wysihtml5.lang.array(types).contains(nextNode.nodeType)) || // nodeTypes check. (options && options.ignoreBlankTexts && wysihtml5.dom.domNode(nextNode).is.emptyTextNode(true)) // blank text nodes bypassed if set ) { @@ -8082,7 +8106,7 @@ wysihtml5.dom.copyAttributes = function(attributesToCopy) { } } - if (properties.nodeName && node.nodeName !== properties.nodeName) { + if (properties.nodeName && node.nodeName.toLowerCase() !== properties.nodeName.toLowerCase()) { return false; } @@ -12532,15 +12556,40 @@ wysihtml5.quirks.ensureProperClearing = (function() { * Select line where the caret is in */ selectLine: function() { + var r = rangy.createRange(); if (wysihtml5.browser.supportsSelectionModify()) { this._selectLine_W3C(); - } else if (this.doc.selection) { - this._selectLine_MSIE(); - } else { - // For IE Edge as it ditched the old api and did not fully implement the new one (as expected) - this._selectLineUniversal(); + } else if (r.nativeRange && r.nativeRange.getBoundingClientRect) { + // For IE Edge as it ditched the old api and did not fully implement the new one (as expected)*/ + this._selectLineUniversal(); } }, + + includeRangyRangeHelpers: function() { + var s = this.getSelection(), + r = s.getRangeAt(0), + isHelperNode = function(node) { + return (node && node.nodeType === 1 && node.classList.contains('rangySelectionBoundary')); + }, + getNodeLength = function (node) { + if (node.nodeType === 1) { + return node.childNodes && node.childNodes.length || 0; + } else { + return node.data && node.data.length || 0; + } + // body... + }, + anode = s.anchorNode.nodeType === 1 ? s.anchorNode.childNodes[s.anchorOffset] : s.anchorNode, + fnode = s.focusNode.nodeType === 1 ? s.focusNode.childNodes[s.focusOffset] : s.focusNode; + + if (fnode && s.focusOffset === getNodeLength(fnode) && fnode.nextSibling && isHelperNode(fnode.nextSibling)) { + r.setEndAfter(fnode.nextSibling); + } + if (anode && s.anchorOffset === 0 && anode.previousSibling && isHelperNode(anode.previousSibling)) { + r.setStartBefore(anode.previousSibling); + } + r.select(); + }, /** * See https://developer.mozilla.org/en/DOM/Selection/modify @@ -12559,6 +12608,8 @@ wysihtml5.quirks.ensureProperClearing = (function() { selection.focusOffset === initialBoundry[3] ) { this._selectLineUniversal(); + } else { + this.includeRangyRangeHelpers(); } }, @@ -12610,19 +12661,45 @@ wysihtml5.quirks.ensureProperClearing = (function() { rect, startRange, endRange, testRange, count = 0, - amount, testRect, found; + amount, testRect, found, + that = this, + isLineBreakingElement = function(el) { + return el && el.nodeType === 1 && (that.win.getComputedStyle(el).display === "block" || wysihtml5.lang.array(['BR', 'HR']).contains(el.nodeName)); + }, + prevNode = function(node) { + var pnode = node; + if (pnode) { + while (pnode && ((pnode.nodeType === 1 && pnode.classList.contains('rangySelectionBoundary')) || (pnode.nodeType === 3 && (/^\s*$/).test(pnode.data)))) { + pnode = pnode.previousSibling; + } + } + return pnode; + }; startRange = r.cloneRange(); endRange = r.cloneRange(); if (r.collapsed) { - r.expand('word', 1); - rect = r.nativeRange.getBoundingClientRect(); + // Collapsed state can not have a bounding rect. Thus need to expand it at least by 1 character first while not crossing line boundary + // TODO: figure out a shorter and more readable way + if (r.startContainer.nodeType === 3 && r.startOffset < r.startContainer.data.length) { + r.moveEnd('character', 1); + } else if (r.startContainer.nodeType === 1 && r.startContainer.childNodes[r.startOffset] && r.startContainer.childNodes[r.startOffset].nodeType === 3 && r.startContainer.childNodes[r.startOffset].data.length > 0) { + r.moveEnd('character', 1); + } else if (r.startOffset > 0 && ( r.startContainer.nodeType === 3 || (r.startContainer.nodeType === 1 && !isLineBreakingElement(prevNode(r.startContainer.childNodes[r.startOffset - 1]))))) { + r.moveStart('character', -1); + } } - + if (!r.collapsed) { + r.insertNode(this.doc.createTextNode(wysihtml5.INVISIBLE_SPACE)); + } + + // Is probably just empty line as can not be expanded + rect = r.nativeRange.getBoundingClientRect(); do { amount = r.moveStart('character', -1); testRect = r.nativeRange.getBoundingClientRect(); + if (!testRect || Math.floor(testRect.top) !== Math.floor(rect.top)) { r.moveStart('character', 1); found = true; @@ -12638,61 +12715,24 @@ wysihtml5.quirks.ensureProperClearing = (function() { testRect = r.nativeRange.getBoundingClientRect(); if (!testRect || Math.floor(testRect.bottom) !== Math.floor(rect.bottom)) { r.moveEnd('character', -1); + + // Fix a IE line end marked by linebreak element although caret is before it + // If causes problems should be changed to be applied only to IE + if (r.endContainer && r.endContainer.nodeType === 1 && r.endContainer.childNodes[r.endOffset] && r.endContainer.childNodes[r.endOffset].nodeType === 1 && r.endContainer.childNodes[r.endOffset].nodeName === "BR" && r.endContainer.childNodes[r.endOffset].previousSibling) { + if (r.endContainer.childNodes[r.endOffset].previousSibling.nodeType === 1) { + r.setEnd(r.endContainer.childNodes[r.endOffset].previousSibling, r.endContainer.childNodes[r.endOffset].previousSibling.childNodes.length); + } else if (r.endContainer.childNodes[r.endOffset].previousSibling.nodeType === 3) { + r.setEnd(r.endContainer.childNodes[r.endOffset].previousSibling, r.endContainer.childNodes[r.endOffset].previousSibling.data.length); + } + } + found = true; } count++; } while (amount !== 0 && !found && count < 2000); r.select(); - }, - - _selectLine_MSIE: function() { - var range = this.doc.selection && this.doc.selection.createRange ? this.doc.selection.createRange() : this.doc.createRange(), - rangeTop = range.boundingTop, - scrollWidth = this.doc.body.scrollWidth, - rangeBottom, - rangeEnd, - measureNode, - i, - j; - - window.r = range; - - if (!range.moveToPoint) { - return; - } - - if (rangeTop === 0) { - // Don't know why, but when the selection ends at the end of a line - // range.boundingTop is 0 - measureNode = this.doc.createElement("span"); - this.insertNode(measureNode); - rangeTop = measureNode.offsetTop; - measureNode.parentNode.removeChild(measureNode); - } - - rangeTop += 1; - - for (i=-10; i=0; j--) { - try { - rangeEnd.moveToPoint(j, rangeBottom); - break; - } catch(e2) {} - } - - range.setEndPoint("EndToEnd", rangeEnd); - range.select(); + this.includeRangyRangeHelpers(); }, getText: function() { @@ -13993,18 +14033,56 @@ wysihtml5.Commands = Base.extend( }; } + function getRangeNode(node, offset) { + if (node.nodeType === 3) { + return node; + } else { + return node.childNodes[offset] || node; + } + } + + // Returns if node is a line break + function isBr(n) { + return n && n.nodeType === 1 && n.nodeName === "BR"; + } + + // Is block level element + function isBlock(n, composer) { + return n && n.nodeType === 1 && composer.win.getComputedStyle(n).display === "block"; + } + + // Returns if node is the rangy selection bookmark element (that must not be taken into account in most situatons and is removed on selection restoring) + function isBookmark(n) { + return n && n.nodeType === 1 && n.classList.contains('rangySelectionBoundary'); + } + + // Is line breaking node + function isLineBreaking(n, composer) { + return isBr(n) || isBlock(n, composer); + } + // Removes empty block level elements - function cleanup(composer) { + function cleanup(composer, newBlockElements) { + wysihtml5.dom.removeInvisibleSpaces(composer.element); var container = composer.element, allElements = container.querySelectorAll(BLOCK_ELEMENTS), - uneditables = container.querySelectorAll(composer.config.classNames.uneditableContainer), - elements = wysihtml5.lang.array(allElements).without(uneditables); + noEditQuery = composer.config.classNames.uneditableContainer + ([""]).concat(BLOCK_ELEMENTS.split(',')).join(", " + composer.config.classNames.uneditableContainer + ' '), + uneditables = container.querySelectorAll(noEditQuery), + elements = wysihtml5.lang.array(allElements).without(uneditables), // Lets not touch uneditable elements and their contents + nbIdx; for (var i = elements.length; i--;) { if (elements[i].innerHTML.replace(/[\uFEFF]/g, '') === "") { + // If cleanup removes some new block elements. remove them from newblocks array too + nbIdx = wysihtml5.lang.array(newBlockElements).indexOf(elements[i]); + if (nbIdx > -1) { + newBlockElements.splice(nbIdx, 1); + } elements[i].parentNode.removeChild(elements[i]); } } + + return newBlockElements; } function defaultNodeName(composer) { @@ -14026,13 +14104,15 @@ wysihtml5.Commands = Base.extend( return block; } + // Clone for splitting the inner inline element out of its parent inline elements context + // For example if selection is in bold and italic, clone the outer nodes and wrap these around content and return function cloneOuterInlines(node, container) { var n = node, innerNode, parentNode, el = null, el2; - + while (n && container && n !== container) { if (n.nodeType === 1 && n.matches(INLINE_ELEMENTS)) { parentNode = n; @@ -14088,7 +14168,10 @@ wysihtml5.Commands = Base.extend( // Unsets element properties by options // If nodename given and matches current element, element is unwrapped or converted to default node (depending on presence of class and style attributes) function removeOptionsFromElement(element, options, composer) { - var style, classes; + var style, classes, + prevNode = element.previousSibling, + nextNode = element.nextSibling, + unwrapped = false; if (options.styleProperty) { element.style[wysihtml5.browser.fixStyleKey(options.styleProperty)] = ''; @@ -14106,10 +14189,11 @@ wysihtml5.Commands = Base.extend( element.removeAttribute('class'); } - if (options.nodeName && element.nodeName === options.nodeName) { + if (options.nodeName && element.nodeName.toLowerCase() === options.nodeName.toLowerCase()) { style = element.getAttribute('style'); if (!style || style.trim() === '') { dom.unwrap(element); + unwrapped = true; } else { element = dom.renameElement(element, defaultNodeName(composer)); } @@ -14119,60 +14203,79 @@ wysihtml5.Commands = Base.extend( if (element.getAttribute('style') !== null && element.getAttribute('style').trim() === "") { element.removeAttribute('style'); } + + if (unwrapped) { + applySurroundingLineBreaks(prevNode, nextNode, composer); + } } // Unwraps block level elements from inside content // Useful as not all block level elements can contain other block-levels function unwrapBlocksFromContent(element) { - var contentBlocks = element.querySelectorAll(BLOCK_ELEMENTS) || []; // Find unnestable block elements in extracted contents + var blocks = element.querySelectorAll(BLOCK_ELEMENTS) || [], // Find unnestable block elements in extracted contents + nextEl, prevEl; - for (var i = contentBlocks.length; i--;) { - if (!contentBlocks[i].nextSibling || contentBlocks[i].nextSibling.nodeType !== 1 || contentBlocks[i].nextSibling.nodeName !== 'BR') { - if ((contentBlocks[i].innerHTML || contentBlocks[i].nodeValue || '').trim() !== '') { - contentBlocks[i].parentNode.insertBefore(contentBlocks[i].ownerDocument.createElement('BR'), contentBlocks[i].nextSibling); + for (var i = blocks.length; i--;) { + nextEl = wysihtml5.dom.domNode(blocks[i]).next({nodeTypes: [1,3], ignoreBlankTexts: true}), + prevEl = wysihtml5.dom.domNode(blocks[i]).prev({nodeTypes: [1,3], ignoreBlankTexts: true}); + + if (nextEl && nextEl.nodeType !== 1 && nextEl.nodeName !== 'BR') { + if ((blocks[i].innerHTML || blocks[i].nodeValue || '').trim() !== '') { + blocks[i].parentNode.insertBefore(blocks[i].ownerDocument.createElement('BR'), nextEl); } } - wysihtml5.dom.unwrap(contentBlocks[i]); + if (nextEl && nextEl.nodeType !== 1 && nextEl.nodeName !== 'BR') { + if ((blocks[i].innerHTML || blocks[i].nodeValue || '').trim() !== '') { + blocks[i].parentNode.insertBefore(blocks[i].ownerDocument.createElement('BR'), nextEl); + } + } + wysihtml5.dom.unwrap(blocks[i]); } } // Fix ranges that visually cover whole block element to actually cover the block function fixRangeCoverage(range, composer) { - var node; + var node, + start = range.startContainer, + end = range.endContainer; - if (range.startContainer && range.startContainer.nodeType === 1 && range.startContainer === range.endContainer) { - if (range.startContainer.firstChild === range.startContainer.lastChild && range.endOffset === 1) { - if (range.startContainer !== composer.element) { - range.setStartBefore(range.startContainer); - range.setEndAfter(range.endContainer); + // If range has only one childNode and it is end to end the range, extend the range to contain the container element too + // This ensures the wrapper node is modified and optios added to it + if (start && start.nodeType === 1 && start === end) { + if (start.firstChild === start.lastChild && range.endOffset === 1) { + if (start !== composer.element && start.nodeName !== 'LI' && start.nodeName !== 'TD') { + range.setStartBefore(start); + range.setEndAfter(end); } } return; } - if (range.startContainer && range.startContainer.nodeType === 1 && range.endContainer.nodeType === 3) { - if (range.startContainer.firstChild === range.endContainer && range.endOffset === 1) { - if (range.startContainer !== composer.element) { - range.setEndAfter(range.startContainer); + // If range starts outside of node and ends inside at textrange and covers the whole node visually, extend end to cover the node end too + if (start && start.nodeType === 1 && end.nodeType === 3) { + if (start.firstChild === end && range.endOffset === end.data.length) { + if (start !== composer.element && start.nodeName !== 'LI' && start.nodeName !== 'TD') { + range.setEndAfter(start); } } return; } - - if (range.endContainer && range.endContainer.nodeType === 1 && range.startContainer.nodeType === 3) { - if (range.endContainer.firstChild === range.startContainer && range.endOffset === 1) { - if (range.endContainer !== composer.element) { - range.setStartBefore(range.endContainer); + + // If range ends outside of node and starts inside at textrange and covers the whole node visually, extend start to cover the node start too + if (end && end.nodeType === 1 && start.nodeType === 3) { + if (end.firstChild === start && range.startOffset === 0) { + if (end !== composer.element && end.nodeName !== 'LI' && end.nodeName !== 'TD') { + range.setStartBefore(end); } } return; } - - if (range.startContainer && range.startContainer.nodeType === 3 && range.startContainer === range.endContainer && range.startContainer.parentNode) { - if (range.startContainer.parentNode.firstChild === range.startContainer && range.endOffset == range.endContainer.length && range.startOffset === 0) { - node = range.startContainer.parentNode; - if (node !== composer.element) { + // If range covers a whole textnode and the textnode is the only child of node, extend range to node + if (start && start.nodeType === 3 && start === end && start.parentNode.childNodes.length === 1) { + if (range.endOffset == end.data.length && range.startOffset === 0) { + node = start.parentNode; + if (node !== composer.element && node.nodeName !== 'LI' && node.nodeName !== 'TD') { range.setStartBefore(node); range.setEndAfter(node); } @@ -14180,108 +14283,285 @@ wysihtml5.Commands = Base.extend( return; } } + + // Scans ranges array for insertion points that are not allowed to insert block tags fixes/splits illegal ranges + // Some places do not allow block level elements inbetween (inside ul and outside li) + // TODO: might need extending for other nodes besides li (maybe dd,dl,dt) + function fixNotPermittedInsertionPoints(ranges) { + var newRanges = [], + lis, j, maxj, tmpRange, rangePos, closestLI; + + for (var i = 0, maxi = ranges.length; i < maxi; i++) { + + // Fixes range start and end positions if inside UL or OL element (outside of LI) + if (ranges[i].startContainer.nodeType === 1 && ranges[i].startContainer.matches('ul, ol')) { + ranges[i].setStart(ranges[i].startContainer.childNodes[ranges[i].startOffset], 0); + } + if (ranges[i].endContainer.nodeType === 1 && ranges[i].endContainer.matches('ul, ol')) { + closestLI = ranges[i].endContainer.childNodes[Math.max(ranges[i].endOffset - 1, 0)]; + if (closestLI.childNodes) { + ranges[i].setEnd(closestLI, closestLI.childNodes.length); + } + } - // Wrap the range with a block level element - // If element is one of unnestable block elements (ex: h2 inside h1), split nodes and insert between so nesting does not occur - function wrapRangeWithElement(range, options, defaultName, composer) { - var defaultOptions = (options) ? wysihtml5.lang.object(options).clone(true) : null; - if (defaultOptions) { - defaultOptions.nodeName = defaultOptions.nodeName || defaultName || defaultNodeName(composer); + // Get all LI eleemnts in selection (fully or partially covered) + // And make sure ranges are either inside LI or outside UL/OL + // Split and add new ranges as needed to cover same range content + // TODO: Needs improvement to accept DL, DD, DT + lis = ranges[i].getNodes([1], function(node) { + return node.nodeName === "LI"; + }); + if (lis.length > 0) { + + for (j = 0, maxj = lis.length; j < maxj; j++) { + rangePos = ranges[i].compareNode(lis[j]); + + // Fixes start of range that crosses LI border + if (rangePos === ranges[i].NODE_AFTER || rangePos === ranges[i].NODE_INSIDE) { + // Range starts before and ends inside the node + + tmpRange = ranges[i].cloneRange(); + closestLI = wysihtml5.dom.domNode(lis[j]).prev({nodeTypes: [1]}); + + if (closestLI) { + tmpRange.setEnd(closestLI, closestLI.childNodes.length); + } else if (lis[j].closest('ul, ol')) { + tmpRange.setEndBefore(lis[j].closest('ul, ol')); + } else { + tmpRange.setEndBefore(lis[j]); + } + newRanges.push(tmpRange); + ranges[i].setStart(lis[j], 0); + } + + // Fixes end of range that crosses li border + if (rangePos === ranges[i].NODE_BEFORE || rangePos === ranges[i].NODE_INSIDE) { + // Range starts inside the node and ends after node + + tmpRange = ranges[i].cloneRange(); + tmpRange.setEnd(lis[j], lis[j].childNodes.length); + newRanges.push(tmpRange); + + // Find next LI in list and if present set range to it, else + closestLI = wysihtml5.dom.domNode(lis[j]).next({nodeTypes: [1]}); + if (closestLI) { + ranges[i].setStart(closestLI, 0); + } else if (lis[j].closest('ul, ol')) { + ranges[i].setStartAfter(lis[j].closest('ul, ol')); + } else { + ranges[i].setStartAfter(lis[j]); + } + } + } + newRanges.push(ranges[i]); + } else { + newRanges.push(ranges[i]); + } } - fixRangeCoverage(range, composer); + return newRanges; + } + + // Return options object with nodeName set if original did not have any + // Node name is set to local or global default + function getOptionsWithNodename(options, defaultName, composer) { + var correctedOptions = (options) ? wysihtml5.lang.object(options).clone(true) : null; + if (correctedOptions) { + correctedOptions.nodeName = correctedOptions.nodeName || defaultName || defaultNodeName(composer); + } + return correctedOptions; + } + + // Injects document fragment to range ensuring outer elements are split to a place where block elements are allowed to be inserted + // Also wraps empty clones of split parent tags around fragment to keep formatting + // If firstOuterBlock is given assume that instead of finding outer (useful for solving cases of some blocks are allowed into others while others are not) + function injectFragmentToRange(fragment, range, composer, firstOuterBlock) { + var rangeStartContainer = range.startContainer, + firstOuterBlock = firstOuterBlock || findOuterBlock(rangeStartContainer, composer.element, true), + outerInlines, first, last, prev, next; + + if (firstOuterBlock) { + // If selection starts inside un-nestable block, split-escape the unnestable point and insert node between + first = fragment.firstChild; + last = fragment.lastChild; + + composer.selection.splitElementAtCaret(firstOuterBlock, fragment); + next = wysihtml5.dom.domNode(last).next({nodeTypes: [1,3], ignoreBlankTexts: true}); + prev = wysihtml5.dom.domNode(first).prev({nodeTypes: [1,3], ignoreBlankTexts: true}); + + if (first && !isLineBreaking(first, composer) && prev && !isLineBreaking(prev, composer)) { + first.parentNode.insertBefore(composer.doc.createElement('br'), first); + } + + if (last && !isLineBreaking(last, composer) && next && !isLineBreaking(next, composer)) { + next.parentNode.insertBefore(composer.doc.createElement('br'), next); + } + + } else { + // Ensure node does not get inserted into an inline where it is not allowed + outerInlines = cloneOuterInlines(rangeStartContainer, composer.element); + if (outerInlines.outerNode && outerInlines.innerNode && outerInlines.parent) { + if (fragment.childNodes.length === 1) { + while(fragment.firstChild.firstChild) { + outerInlines.innerNode.appendChild(fragment.firstChild.firstChild); + } + fragment.firstChild.appendChild(outerInlines.outerNode); + } + composer.selection.splitElementAtCaret(outerInlines.parent, fragment); + } else { + // Otherwise just insert + range.insertNode(fragment); + } + } + } + + // Removes all block formatting from range + function clearRangeBlockFromating(range, closestBlockName, composer) { var r = range.cloneRange(), + prevNode = getRangeNode(r.startContainer, r.startOffset).previousSibling, + nextNode = getRangeNode(r.endContainer, r.endOffset).nextSibling, + content = r.extractContents(), + fragment = composer.doc.createDocumentFragment(), + children, blocks, + first = true; + + while(content.firstChild) { + // Iterate over all selection content first level childNodes + if (content.firstChild.nodeType === 1 && content.firstChild.matches(BLOCK_ELEMENTS)) { + // If node is a block element + // Split block formating and add new block to wrap caret + + unwrapBlocksFromContent(content.firstChild); + children = wysihtml5.dom.unwrap(content.firstChild); + + // Add line break before if needed + if (children.length > 0) { + if ( + (fragment.lastChild && (fragment.lastChild.nodeType !== 1 || !isLineBreaking(fragment.lastChild, composer))) || + (!fragment.lastChild && prevNode && (prevNode.nodeType !== 1 || isLineBreaking(prevNode, composer))) + ){ + fragment.appendChild(composer.doc.createElement('BR')); + } + } + + for (var c = 0, cmax = children.length; c < cmax; c++) { + fragment.appendChild(children[c]); + } + + // Add line break after if needed + if (children.length > 0) { + if (fragment.lastChild.nodeType !== 1 || !isLineBreaking(fragment.lastChild, composer)) { + if (nextNode || fragment.lastChild !== content.lastChild) { + fragment.appendChild(composer.doc.createElement('BR')); + } + } + } + + } else { + fragment.appendChild(content.firstChild); + } + + first = false; + } + blocks = wysihtml5.lang.array(fragment.childNodes).get(); + injectFragmentToRange(fragment, r, composer); + return blocks; + } + + // When block node is inserted, look surrounding nodes and remove surplous linebreak tags (as block format breaks line itself) + function removeSurroundingLineBreaks(prevNode, nextNode, composer) { + var prevPrev = prevNode && wysihtml5.dom.domNode(prevNode).prev({nodeTypes: [1,3], ignoreBlankTexts: true}); + if (isBr(nextNode)) { + nextNode.parentNode.removeChild(nextNode); + } + if (isBr(prevNode) && (!prevPrev || prevPrev.nodeType !== 1 || composer.win.getComputedStyle(prevPrev).display !== "block")) { + prevNode.parentNode.removeChild(prevNode); + } + } + + function applySurroundingLineBreaks(prevNode, nextNode, composer) { + var prevPrev; + + if (prevNode && isBookmark(prevNode)) { + prevNode = prevNode.previousSibling; + } + if (nextNode && isBookmark(nextNode)) { + nextNode = nextNode.nextSibling; + } + + prevPrev = prevNode && prevNode.previousSibling; + + if (prevNode && (prevNode.nodeType !== 1 || (composer.win.getComputedStyle(prevNode).display !== "block" && !isBr(prevNode))) && prevNode.parentNode) { + prevNode.parentNode.insertBefore(composer.doc.createElement('br'), prevNode.nextSibling); + } + + if (nextNode && (nextNode.nodeType !== 1 || composer.win.getComputedStyle(nextNode).display !== "block") && nextNode.parentNode) { + nextNode.parentNode.insertBefore(composer.doc.createElement('br'), nextNode); + } + } + + // Wrap the range with a block level element + // If element is one of unnestable block elements (ex: h2 inside h1), split nodes and insert between so nesting does not occur + function wrapRangeWithElement(range, options, closestBlockName, composer) { + var similarOptions = options ? correctOptionsForSimilarityCheck(options) : null, + r = range.cloneRange(), rangeStartContainer = r.startContainer, + prevNode = wysihtml5.dom.domNode(getRangeNode(r.startContainer, r.startOffset)).prev({nodeTypes: [1,3], ignoreBlankTexts: true}), + nextNode = wysihtml5.dom.domNode(getRangeNode(r.endContainer, r.endOffset)).next({nodeTypes: [1,3], ignoreBlankTexts: true}), content = r.extractContents(), fragment = composer.doc.createDocumentFragment(), - similarOptions = defaultOptions ? correctOptionsForSimilarityCheck(defaultOptions) : null, similarOuterBlock = similarOptions ? wysihtml5.dom.getParentElement(rangeStartContainer, similarOptions, null, composer.element) : null, - splitAllBlocks = !defaultOptions || (defaultName === "BLOCKQUOTE" && defaultOptions.nodeName && defaultOptions.nodeName === "BLOCKQUOTE"), + splitAllBlocks = !closestBlockName || !options || (options.nodeName === "BLOCKQUOTE" && closestBlockName === "BLOCKQUOTE"), firstOuterBlock = similarOuterBlock || findOuterBlock(rangeStartContainer, composer.element, splitAllBlocks), // The outermost un-nestable block element parent of selection start wrapper, blocks, children; - if (options && options.nodeName && options.nodeName === "BLOCKQUOTE") { + if (options && options.nodeName === "BLOCKQUOTE") { + + // If blockquote is to be inserted no quessing just add it as outermost block on line or selection var tmpEl = applyOptionsToElement(null, options, composer); tmpEl.appendChild(content); fragment.appendChild(tmpEl); blocks = [tmpEl]; + } else { if (!content.firstChild) { + // IF selection is caret (can happen if line is empty) add format around tag fragment.appendChild(applyOptionsToElement(null, options, composer)); } else { while(content.firstChild) { + // Iterate over all selection content first level childNodes if (content.firstChild.nodeType == 1 && content.firstChild.matches(BLOCK_ELEMENTS)) { - if (options) { - // Escape(split) block formatting at caret - applyOptionsToElement(content.firstChild, options, composer); - if (content.firstChild.matches(UNNESTABLE_BLOCK_ELEMENTS)) { - unwrapBlocksFromContent(content.firstChild); - } - fragment.appendChild(content.firstChild); - - } else { - // Split block formating and add new block to wrap caret + // If node is a block element + // Escape(split) block formatting at caret + applyOptionsToElement(content.firstChild, options, composer); + if (content.firstChild.matches(UNNESTABLE_BLOCK_ELEMENTS)) { unwrapBlocksFromContent(content.firstChild); - children = wysihtml5.dom.unwrap(content.firstChild); - for (var c = 0, cmax = children.length; c < cmax; c++) { - fragment.appendChild(children[c]); - } - - if (fragment.childNodes.length > 0) { - fragment.appendChild(composer.doc.createElement('BR')); - } } + fragment.appendChild(content.firstChild); + } else { - - if (options) { - // Wrap subsequent non-block nodes inside new block element - wrapper = applyOptionsToElement(null, defaultOptions, composer); - while(content.firstChild && (content.firstChild.nodeType !== 1 || !content.firstChild.matches(BLOCK_ELEMENTS))) { - if (content.firstChild.nodeType == 1 && wrapper.matches(UNNESTABLE_BLOCK_ELEMENTS)) { - unwrapBlocksFromContent(content.firstChild); - } - wrapper.appendChild(content.firstChild); - } - fragment.appendChild(wrapper); - } else { - // Escape(split) block formatting at selection - if (content.firstChild.nodeType == 1) { + // Wrap subsequent non-block nodes inside new block element + wrapper = applyOptionsToElement(null, getOptionsWithNodename(options, closestBlockName, composer), composer); + while(content.firstChild && (content.firstChild.nodeType !== 1 || !content.firstChild.matches(BLOCK_ELEMENTS))) { + if (content.firstChild.nodeType == 1 && wrapper.matches(UNNESTABLE_BLOCK_ELEMENTS)) { unwrapBlocksFromContent(content.firstChild); } - fragment.appendChild(content.firstChild); + wrapper.appendChild(content.firstChild); } - + fragment.appendChild(wrapper); } } } blocks = wysihtml5.lang.array(fragment.childNodes).get(); } - if (firstOuterBlock) { - // If selection starts inside un-nestable block, split-escape the unnestable point and insert node between - composer.selection.splitElementAtCaret(firstOuterBlock, fragment); - } else { - // Ensure node does not get inserted into an inline where it is not allowed - var outerInlines = cloneOuterInlines(rangeStartContainer, composer.element); - if (outerInlines.outerNode && outerInlines.innerNode && outerInlines.parent) { - if (fragment.childNodes.length === 1) { - while(fragment.firstChild.firstChild) { - outerInlines.innerNode.appendChild(fragment.firstChild.firstChild); - } - fragment.firstChild.appendChild(outerInlines.outerNode); - } - composer.selection.splitElementAtCaret(outerInlines.parent, fragment); - } else { - // Otherwise just insert - r.insertNode(fragment); - } - } - + injectFragmentToRange(fragment, r, composer, firstOuterBlock); + removeSurroundingLineBreaks(prevNode, nextNode, composer); return blocks; } @@ -14293,101 +14573,154 @@ wysihtml5.Commands = Base.extend( return (parentNode) ? parentNode.nodeName : null; } + + // Expands caret to cover the closest block that: + // * cannot contain other block level elements (h1-6,p, etc) + // * Has the same nodeName that is to be inserted + // * has insertingNodeName + // * is DIV if insertingNodeName is not present + // + // If nothing found selects the current line + function expandCaretToBlock(composer, insertingNodeName) { + var parent = wysihtml5.dom.getParentElement(composer.selection.getOwnRanges()[0].startContainer, { + query: UNNESTABLE_BLOCK_ELEMENTS + ', ' + (insertingNodeName ? insertingNodeName.toLowerCase() : 'div'), + }, null, composer.element), + range; + + if (parent) { + range = composer.selection.createRange(); + range.selectNode(parent); + composer.selection.setSelection(range); + } else if (!composer.isEmpty()) { + composer.selection.selectLine(); + } + } + + // Set selection to begin inside first created block element (beginning of it) and end inside (and after content) of last block element + // TODO: Checking nodetype might be unnescescary as nodes inserted by formatBlock are nodetype 1 anyway + function selectElements(newBlockElements, composer) { + var range = composer.selection.createRange(), + lastEl = newBlockElements[newBlockElements.length - 1], + lastOffset = (lastEl.nodeType === 1 && lastEl.childNodes) ? lastEl.childNodes.length | 0 : lastEl.length || 0; + + range.setStart(newBlockElements[0], 0); + range.setEnd(lastEl, lastOffset); + range.select(); + } + + // Get all ranges from selection (takes out uneditables and out of editor parts) and apply format to each + // Return created/modified block level elements + // Method can be either "apply" or "remove" + function formatSelection(method, composer, options) { + var ranges = composer.selection.getOwnRanges(), + newBlockElements = [], + closestBlockName; + + // Some places do not allow block level elements inbetween (inside ul and outside li, inside table and outside of td/th) + ranges = fixNotPermittedInsertionPoints(ranges); + + for (var i = ranges.length; i--;) { + fixRangeCoverage(ranges[i], composer); + closestBlockName = getParentBlockNodeName(ranges[i].startContainer, composer); + if (method === "remove") { + newBlockElements = newBlockElements.concat(clearRangeBlockFromating(ranges[i], closestBlockName, composer)); + } else { + newBlockElements = newBlockElements.concat(wrapRangeWithElement(ranges[i], options, closestBlockName, composer)); + } + } + return newBlockElements; + } + + // If properties is passed as a string, look for tag with that tagName/query + function parseOptions(options) { + if (typeof options === "string") { + options = { + nodeName: options.toUpperCase() + }; + } + return options; + } wysihtml5.commands.formatBlock = { exec: function(composer, command, options) { + options = parseOptions(options); var newBlockElements = [], - placeholder, ranges, range, parent, bookmark, state; - - // If properties is passed as a string, look for tag with that tagName/query - if (typeof options === "string") { - options = { - nodeName: options.toUpperCase() - }; - } + ranges, range, bookmark, state, closestBlockName; - // Remove state if toggle set and state on and selection is collapsed + // Find if current format state is active if options.toggle is set as true + // In toggle case active state elemets are formatted instead of working directly on selection if (options && options.toggle) { state = this.state(composer, command, options); - if (state) { - bookmark = rangy.saveSelection(composer.win); - for (var j = 0, jmax = state.length; j < jmax; j++) { - removeOptionsFromElement(state[j], options, composer); - } - } } + if (state) { + // Remove format from state nodes if toggle set and state on and selection is collapsed + bookmark = rangy.saveSelection(composer.win); + for (var j = 0, jmax = state.length; j < jmax; j++) { + removeOptionsFromElement(state[j], options, composer); + } - // Otherwise expand selection so it will cover closest block if option caretSelectsBlock is true and selection is collapsed - if (!state) { - + } else { + // If selection is caret expand it to cover nearest suitable block element or row if none found if (composer.selection.isCollapsed()) { - parent = wysihtml5.dom.getParentElement(composer.selection.getOwnRanges()[0].startContainer, { - query: UNNESTABLE_BLOCK_ELEMENTS + ', ' + (options && options.nodeName ? options.nodeName.toLowerCase() : 'div'), - }, null, composer.element); - if (parent) { - bookmark = rangy.saveSelection(composer.win); - range = composer.selection.createRange(); - range.selectNode(parent); - composer.selection.setSelection(range); - } else if (!composer.isEmpty()) { - bookmark = rangy.saveSelection(composer.win); - composer.selection.selectLine(); - } + bookmark = rangy.saveSelection(composer.win); + expandCaretToBlock(composer, options && options.nodeName ? options.nodeName.toUpperCase() : undefined); } - - // And get all selection ranges of current composer and iterate - ranges = composer.selection.getOwnRanges(); - for (var i = ranges.length; i--;) { - newBlockElements = newBlockElements.concat(wrapRangeWithElement(ranges[i], options, getParentBlockNodeName(ranges[i].startContainer, composer), composer)); + if (options) { + newBlockElements = formatSelection("apply", composer, options); + } else { + // Options == null means block formatting should be removed from selection + newBlockElements = formatSelection("remove", composer); } - + } // Remove empty block elements that may be left behind - cleanup(composer); - // If cleanup removed some new block elements. remove them from array too - for (var e = newBlockElements.length; e--;) { - if (!newBlockElements[e].parentNode) { - newBlockElements.splice(e, 1); - } - } + // Also remove them from new blocks list + newBlockElements = cleanup(composer, newBlockElements); - // Restore correct selection + // Restore selection if (bookmark) { - wysihtml5.dom.removeInvisibleSpaces(composer.element); rangy.restoreSelection(bookmark); } else { - wysihtml5.dom.removeInvisibleSpaces(composer.element); - // Set selection to beging inside first created block element (beginning of it) and end inside (and after content) of last block element - // TODO: Checking nodetype might be unnescescary as nodes inserted by formatBlock are nodetype 1 anyway - range = composer.selection.createRange(); - range.setStart(newBlockElements[0], 0); - var lastEl = newBlockElements[newBlockElements.length - 1], - lastOffset = (lastEl.nodeType === 1 && lastEl.childNodes) ? lastEl.childNodes.length | 0 : lastEl.length || 0; - range.setEnd(lastEl, lastOffset); - range.select(); + selectElements(newBlockElements, composer); } }, - - // If properties as null is passed returns status describing all block level elements - state: function(composer, command, properties) { + + // Removes all block formatting from selection + remove: function(composer, command, options) { + options = parseOptions(options); + var newBlockElements, bookmark; - // If properties is passed as a string, look for tag with that tagName/query - if (typeof properties === "string") { - properties = { - query: properties - }; + // If selection is caret expand it to cover nearest suitable block element or row if none found + if (composer.selection.isCollapsed()) { + bookmark = rangy.saveSelection(composer.win); + expandCaretToBlock(composer, options && options.nodeName ? options.nodeName.toUpperCase() : undefined); } + + newBlockElements = formatSelection("remove", composer); + newBlockElements = cleanup(composer, newBlockElements); + + // Restore selection + if (bookmark) { + rangy.restoreSelection(bookmark); + } else { + selectElements(newBlockElements, composer); + } + }, + + // If options as null is passed returns status describing all block level elements + state: function(composer, command, options) { + options = parseOptions(options); var nodes = composer.selection.filterElements((function (element) { // Finds matching elements inside selection - return wysihtml5.dom.domNode(element).test(properties || { query: BLOCK_ELEMENTS }); + return wysihtml5.dom.domNode(element).test(options || { query: BLOCK_ELEMENTS }); }).bind(this)), parentNodes = composer.selection.getSelectedOwnNodes(), parent; // Finds matching elements that are parents of selection and adds to nodes list for (var i = 0, maxi = parentNodes.length; i < maxi; i++) { - parent = dom.getParentElement(parentNodes[i], properties || { query: BLOCK_ELEMENTS }, null, composer.element); + parent = dom.getParentElement(parentNodes[i], options || { query: BLOCK_ELEMENTS }, null, composer.element); if (parent && nodes.indexOf(parent) === -1) { nodes.push(parent); } @@ -14597,6 +14930,9 @@ wysihtml5.Commands = Base.extend( if (options.toggle !== false && element.classList.contains(options.className)) { element.classList.remove(options.className); } else { + if (options.classRegExp) { + element.className = element.className.replace(options.classRegExp, ''); + } element.classList.add(options.className); } if (hasNoClass(element)) { @@ -16247,7 +16583,7 @@ wysihtml5.views.View = Base.extend( cleanUp: function(rules) { var bookmark; - if (this.selection) { + if (this.selection && this.selection.isInThisEditable()) { bookmark = rangy.saveSelection(this.win); } this.parent.parse(this.element, undefined, rules); @@ -16419,6 +16755,8 @@ wysihtml5.views.View = Base.extend( ]).from(this.textarea.element).to(this.element); } + this._initAutoLinking(); + dom.addClass(this.element, this.config.classNames.composer); // // Make the editor look like the original textarea, by syncing styles @@ -16451,7 +16789,6 @@ wysihtml5.views.View = Base.extend( // Make sure that the browser avoids using inline styles whenever possible this.commands.exec("styleWithCSS", false); - this._initAutoLinking(); this._initObjectResizing(); this._initUndoManager(); this._initLineBreaking(); @@ -16485,10 +16822,7 @@ wysihtml5.views.View = Base.extend( supportsAutoLinking = browser.doesAutoLinkingInContentEditable(); if (supportsDisablingOfAutoLinking) { - // I have no idea why IE edge deletes element content here when calling the command, - var tmpHTML = this.element.innerHTML; this.commands.exec("AutoUrlDetect", false, false); - this.element.innerHTML = tmpHTML; } if (!this.config.autoLink) {